#!/usr/bin/env python3
"""
WARPCLEAN - Python CLI File Organizer
Deterministic, rule-based, content-aware, safe.
"""

# ==============================================================================
# 1. Imports
# ==============================================================================
import os
import sys
import argparse
import logging
import mimetypes
import concurrent.futures
import shutil
import json
import functools
import hashlib
from datetime import datetime
from typing import List, Optional, Callable, Tuple, Dict
from pathlib import Path
from dataclasses import dataclass

try:
    import magic
    HAS_MAGIC = True
except ImportError:
    HAS_MAGIC = False




# ==============================================================================
# UI System (Rich Integration)
# ==============================================================================


# ==============================================================================
# UI System (Rich Integration)
# ==============================================================================

class MockRich:
    """Fallback implementation when Rich is not used or not installed."""
    def __init__(self):
        self._enabled = False
        
    def print(self, text: str, **kwargs):
        """Prints plain text, stripping simple markup if present."""
        import re
        clean = re.sub(r'\[/?[a-zA-Z0-9\s=,#]+\]', '', str(text)) # Rich
        clean = re.sub(r'<[^>]+>', '', clean) # Legacy
        print(clean)
        
    def create_table(self, title: str = "", **kwargs):
        return self.MockTable(title)
        
    def create_tree(self, label: str):
        return self.MockTree(label)
        
    class MockTable:
        # ... (Existing MockTable implementation reused for brevity, assume strictly pasted if I copy-paste everything)
        # Wait, I must provide full implementation or strict replacement of the block.
        # I'll include the MockTable logic from previous step to be safe.
        def __init__(self, title: str):
            self.title = title
            self.columns = []
            self.rows = []
        def add_column(self, name, **kwargs): self.columns.append(name)
        def add_row(self, *args, **kwargs): self.rows.append([str(a) for a in args])
        def __rich_console__(self, console, options): pass
        def __str__(self):
            if not self.columns: return ""
            widths = [len(c) for c in self.columns]
            for row in self.rows:
                for i, cell in enumerate(row):
                    if i < len(widths):
                        content = cell
                        if len(content) > 60: content = content[:57] + "..."
                        widths[i] = max(widths[i], len(content))
            col_widths = [w + 2 for w in widths]
            lines = []
            total_width = sum(col_widths) + len(col_widths) - 1
            if self.title: lines.append(f" {self.title} ".center(total_width, "="))
            else: lines.append("=" * total_width)
            header = "|".join(c.center(w) for c, w in zip(self.columns, col_widths))
            lines.append(header)
            lines.append("-" * total_width)
            for row in self.rows:
                display_row = [cell[:57] + "..." if len(cell) > 60 else cell for cell in row]
                line = "|".join(c.ljust(w) for c, w in zip(display_row, col_widths))
                lines.append(line)
            lines.append("=" * total_width)
            return "\n".join(lines)

    class MockTree:
        def __init__(self, label: str):
            self.label = label
            self.children = []
            
        def add(self, label: str):
            child = MockRich.MockTree(label)
            self.children.append(child)
            return child
            
        def __str__(self, prefix="", is_last=True):
            connector = "`-- " if is_last else "|-- "
            line = f"{prefix}{connector}{self.label}"
            
            child_prefix = prefix + ("    " if is_last else "|   ")
            lines = [line]
            
            count = len(self.children)
            for i, child in enumerate(self.children):
                lines.append(child.__str__(child_prefix, i == count - 1))
            return "\n".join(lines)
    
    class MockProgress:
        """Fallback progress bar when Rich is not available."""
        def __init__(self, *args, **kwargs):
            self.tasks = {}
            self.current_task = None
            
        def __enter__(self):
            return self
            
        def __exit__(self, *args):
            pass
            
        def add_task(self, description, total=None, **kwargs):
            task_id = len(self.tasks)
            self.tasks[task_id] = {"description": description, "total": total, "completed": 0}
            if self.current_task is None:
                self.current_task = task_id
            return task_id
            
        def update(self, task_id, advance=1, **kwargs):
            if task_id in self.tasks:
                self.tasks[task_id]["completed"] += advance
                task = self.tasks[task_id]
                if task["total"]:
                    pct = (task["completed"] / task["total"]) * 100
                    print(f"\r{task['description']}: {task['completed']}/{task['total']} ({pct:.1f}%)", end="", flush=True)
                else:
                    print(f"\r{task['description']}: {task['completed']}", end="", flush=True)
                    
        def refresh(self):
            pass
            
    def create_progress(self, *columns, **kwargs):
        return self.MockProgress(*columns, **kwargs)

# Global UI instance (Default: Plain)
ui = MockRich()

def enable_rich_output():
    """Attempts to enable Rich for colored output."""
    global ui
    try:
        from rich.console import Console
        from rich.table import Table
        from rich.tree import Tree
        from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
        from rich.panel import Panel
        from rich.text import Text
        from rich import box
        
        class RichWrapper:
            def __init__(self):
                self.console = Console()
                self.Progress = Progress
                self.SpinnerColumn = SpinnerColumn
                self.BarColumn = BarColumn
                self.TextColumn = TextColumn
                self.TimeElapsedColumn = TimeElapsedColumn
                self.Panel = Panel
                self.Text = Text
                
            def print(self, *args, **kwargs):
                self.console.print(*args, **kwargs)
                
            def create_table(self, title: str = "", **kwargs):
                return Table(title=title, box=box.ROUNDED, **kwargs)
                
            def create_tree(self, label: str):
                return Tree(label)
                
            def create_progress(self, *columns, **kwargs):
                return Progress(*columns, console=self.console, **kwargs)
                
        ui = RichWrapper()
    except ImportError:
        print("[WARN] Rich not found. Using plain output. (pip install rich)")




# ==============================================================================
# 2. Data Models
# ==============================================================================

@dataclass
class FileInfo:
    """
    Data model representing a single file and its metadata.
    """
    path: Path
    size: int
    extension: str
    mime: str
    ctime: float
    mtime: float
    category: str
    group_id: Optional[str] = None
    target: Optional[Path] = None
    file_hash: Optional[str] = None  # MD5 hash for duplicate detection
    is_duplicate: bool = False  # Flag to mark duplicates

# ==============================================================================
# 3. Scanner
# ==============================================================================

def scan_directory(root: Path, show_progress: bool = False) -> List[os.DirEntry]:
    """
    Recursively scans the directory for files using os.scandir for performance.
    Skips symlinks and hidden files.
    Returns DirEntry objects which contain cached stat() info.
    
    Args:
        root (Path): The root directory to scan.
        show_progress (bool): Show progress bar if Rich is available.
        
    Returns:
        List[os.DirEntry]: A list of file entries found.
    """
    files_found = []
    if not root.exists() or not root.is_dir():
        return []

    # For progress bar, we need an estimate first (optional optimization)
    # For now, use simple scanning with optional progress updates
    stack = [str(root)]
    
    if show_progress and hasattr(ui, 'create_progress'):
        try:
            with ui.create_progress(
                ui.SpinnerColumn(),
                ui.TextColumn("[progress.description]{task.description}"),
                ui.BarColumn(),
            ) as progress:
                task = progress.add_task("Scanning files...", total=None)
                while stack:
                    current_path = stack.pop()
                    try:
                        with os.scandir(current_path) as it:
                            for entry in it:
                                # Skip hidden files/dirs
                                if entry.name.startswith('.'):
                                    continue
                                    
                                if entry.is_symlink():
                                    continue
                                    
                                if entry.is_dir():
                                    stack.append(entry.path)
                                elif entry.is_file():
                                    files_found.append(entry)
                                    progress.update(task, advance=1)
                    except PermissionError:
                        print(f"[WARN] Permission denied: {current_path}")
                        continue
        except Exception:
            # Fallback to non-progress version
            stack = [str(root)]
            while stack:
                current_path = stack.pop()
                try:
                    with os.scandir(current_path) as it:
                        for entry in it:
                            if entry.name.startswith('.'):
                                continue
                            if entry.is_symlink():
                                continue
                            if entry.is_dir():
                                stack.append(entry.path)
                            elif entry.is_file():
                                files_found.append(entry)
                except PermissionError:
                    print(f"[WARN] Permission denied: {current_path}")
                    continue
    else:
        # Non-progress version
        while stack:
            current_path = stack.pop()
            try:
                with os.scandir(current_path) as it:
                    for entry in it:
                        # Skip hidden files/dirs
                        if entry.name.startswith('.'):
                            continue
                            
                        if entry.is_symlink():
                            continue
                            
                        if entry.is_dir():
                            stack.append(entry.path)
                        elif entry.is_file():
                            files_found.append(entry)
            except PermissionError:
                print(f"[WARN] Permission denied: {current_path}")
                continue
            
    return files_found

# ==============================================================================
# 4. Classifier
# ==============================================================================

def _classify_category(mime: str, ext: str) -> str:
    """
    Determines the category based on MIME type and extension.
    """
    # Force extension check for common audio formats that might be missed or mis-typed
    if ext in {'.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.alac', '.aiff'}:
        return 'audio'

    if mime.startswith('image/'):
        return 'image'
    if mime.startswith('video/'):
        return 'video'
    if mime.startswith('audio/'):
        return 'audio'
    
    # Common documents
    if mime.startswith('text/') or 'pdf' in mime or 'msword' in mime or \
       'vnd.openxmlformats-officedocument' in mime or ext in {'.txt', '.md', '.rst'}:
        return 'document'
    
    # Archives
    if 'zip' in mime or 'compressed' in mime or 'tar' in mime or ext in {'.7z', '.rar', '.gz', '.zip', '.tar', '.iso'}:
        return 'archive'
        
    # Executables
    if mime == 'application/x-msdownload' or ext in {'.exe', '.msi', '.bat', '.sh', '.bin', '.cmd', '.ps1'}:
        return 'executable'
        
    return 'other'

def build_fileinfo(entry: os.DirEntry, fast_mode: bool = False) -> FileInfo:
    """
    Builds the FileInfo object for a given DirEntry by extracting metadata.
    Uses cached stat from DirEntry to avoid extra syscalls.
    
    Args:
        entry (os.DirEntry): File entry.
        fast_mode (bool): If True, skip magic content detection (huge speedup).
    """
    path = Path(entry.path)
    try:
        # entry.stat() values are cached on Windows/Linux usually
        stat = entry.stat()
        size = stat.st_size
        ctime = stat.st_ctime
        mtime = stat.st_mtime
    except Exception:
        size = 0
        ctime = 0.0
        mtime = 0.0
    
    extension = path.suffix.lower()
    
    mime = 'application/octet-stream'
    
    # OPTIMIZATION: In Fast Mode, we skip Magic entirety and rely solely on mimetypes.
    # This avoids opening the file and reading the header which is the costliest op.
    if not fast_mode and HAS_MAGIC:
        try:
            # magic readings are I/O bound
            mime = magic.from_file(entry.path, mime=True)
        except Exception:
            pass
            
    if mime == 'application/octet-stream':
        # Fallback to mimetypes (or primary in fast_mode)
        guess, _ = mimetypes.guess_type(path)
        if guess:
            mime = guess

    category = _classify_category(mime, extension)
    
    return FileInfo(
        path=path,
        size=size,
        extension=extension,
        mime=mime,
        ctime=ctime,
        mtime=mtime,
        category=category
    )

def compute_file_hash(file_path: Path, chunk_size: int = 8192) -> str:
    """
    Computes MD5 hash of a file for duplicate detection.
    
    Args:
        file_path (Path): Path to the file.
        chunk_size (int): Chunk size for reading file.
        
    Returns:
        str: MD5 hash of the file.
    """
    hash_md5 = hashlib.md5()
    try:
        with open(file_path, 'rb') as f:
            for chunk in iter(lambda: f.read(chunk_size), b''):
                hash_md5.update(chunk)
        return hash_md5.hexdigest()
    except Exception:
        return ""

def detect_duplicates(files: List[FileInfo], compute_hashes: bool = True) -> None:
    """
    Detects duplicate files by comparing file hashes.
    Marks duplicates by setting is_duplicate flag on FileInfo objects.
    
    Args:
        files (List[FileInfo]): List of FileInfo objects to check.
        compute_hashes (bool): If True, compute hashes for files that don't have them.
    """
    if not files:
        return
    
    # Group by size first (quick filter)
    size_groups: Dict[int, List[FileInfo]] = {}
    for f in files:
        if f.size not in size_groups:
            size_groups[f.size] = []
        size_groups[f.size].append(f)
    
    # Only check files with same size (potential duplicates)
    hash_map: Dict[str, FileInfo] = {}
    
    for size, group in size_groups.items():
        if len(group) < 2:
            continue  # No duplicates possible
        
        for file_info in group:
            if compute_hashes and not file_info.file_hash:
                file_info.file_hash = compute_file_hash(file_info.path)
            
            if file_info.file_hash:
                if file_info.file_hash in hash_map:
                    # Found duplicate - mark this one as duplicate
                    file_info.is_duplicate = True
                else:
                    # First occurrence - keep it
                    hash_map[file_info.file_hash] = file_info

def classify_files(entries: List[os.DirEntry], fast_mode: bool = False, show_progress: bool = False) -> List[FileInfo]:
    """
    Orchestrates the classification of a list of entries.
    Uses ThreadPoolExecutor for performance.
    
    Args:
        entries (List[os.DirEntry]): List of file entries.
        fast_mode (bool): Skip content-based detection.
        show_progress (bool): Show progress bar if Rich is available.
        
    Returns:
        List[FileInfo]: List of classified FileInfo objects.
    """
    
    results = []
    # Increase workers for I/O bound tasks
    max_workers = min(32, (os.cpu_count() or 1) * 4) 
    
    # Partial function to pass fast_mode
    worker = functools.partial(build_fileinfo, fast_mode=fast_mode)
    
    if show_progress and hasattr(ui, 'create_progress'):
        try:
            with ui.create_progress(
                ui.SpinnerColumn(),
                ui.TextColumn("[progress.description]{task.description}"),
                ui.BarColumn(),
                ui.TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
                ui.TimeElapsedColumn(),
            ) as progress:
                task = progress.add_task("Classifying files...", total=len(entries))
                with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
                    for result in executor.map(worker, entries):
                        results.append(result)
                        progress.update(task, advance=1)
        except Exception:
            # Fallback to non-progress version
            with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
                results = list(executor.map(worker, entries))
    else:
        with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
            results = list(executor.map(worker, entries))
        
    return results

# ... (Grouper, Rule Engine, Planner remain unchanged) ...

# ==============================================================================
# 5. Grouper
# ==============================================================================

def group_related_files(files: List[FileInfo]) -> None:
    """
    Groups related files based on heuristics:
    1. Exact stem match (different extensions, e.g. photo.jpg, photo.raw)
    2. Sequence patterns (e.g. img_001.jpg, img_002.jpg, file (1).txt)
    3. Fuzzy string similarity (e.g. 'Project Proposal v1.docx', 'Project Proposal v2.docx')
    
    Sets the `group_id` on the FileInfo objects.
    
    Args:
        files (List[FileInfo]): List of FileInfo objects to group.
    """
    import re
    import difflib
    
    # helper to normalize name for grouping (remove numbers, specific separators)
    def clean_name(name: str) -> str:
        # Remove (1), _01, v2, etc. at end of string
        return re.sub(r'([_ \-\.\(]+[\dv]+[\)\]]*)+$', '', name).lower()

    # 1. Group by exact stem (base name without extension)
    #    Useful for sidecar files (image + xmp)
    by_stem: Dict[str, List[FileInfo]] = {}
    for f in files:
        if f.target or f.group_id: continue
        stem = f.path.stem.lower()
        if stem not in by_stem: by_stem[stem] = []
        by_stem[stem].append(f)
        
    for stem, group in by_stem.items():
        if len(group) > 1:
            # Check if they really relate (usually same directory, but scanner is flat list?)
            # Assuming scanner is flat list.
            group_id = f"Group_{stem}"
            for f in group:
                f.group_id = group_id
    
    # 2. Group by Common Prefix / Sequence / Variation Pattern
    #    We iterate through ungrouped files and find clusters.
    ungrouped = [f for f in files if not f.group_id and not f.target]
    if not ungrouped:
        return
        
    # Sort by name to make sequence detection easier
    ungrouped.sort(key=lambda x: x.path.name)
    
    if len(ungrouped) < 3:
        return

    groups: List[List[FileInfo]] = []
    current_group: List[FileInfo] = [ungrouped[0]]
    
    for i in range(1, len(ungrouped)):
        anchor = current_group[0] # Compare against the first file of the group to catch 'img1', 'img100'
        curr = ungrouped[i]
        
        is_match = False
        
        # 1. Cleaned name match (Strongest)
        clean_anchor = clean_name(anchor.path.stem)
        clean_curr = clean_name(curr.path.stem)
        
        if clean_anchor and clean_anchor == clean_curr:
            is_match = True
        else:
            # 2. Fuzzy match
            # If start letter is same and length is comparable
            if anchor.path.name[0].lower() == curr.path.name[0].lower():
                ratio = difflib.SequenceMatcher(None, anchor.path.stem, curr.path.stem).ratio()
                # 0.7 allows for v1 vs final (e.g. "report v1", "report final") -> "report " is common
                if ratio > 0.7: 
                    is_match = True
                    
        if is_match:
            current_group.append(curr)
        else:
            # Close previous group if valid
            if len(current_group) >= 3:
                groups.append(current_group)
            
            # Start new group
            current_group = [curr]
            
    # Final group check
    if len(current_group) >= 3:
        groups.append(current_group)
        
    # Assign Group IDs
    for group in groups:
        # Determine best group name
        # 1. Longest Common Prefix
        prefix = os.path.commonprefix([f.path.stem for f in group])
        # Clean the prefix (remove trailing numbers/separators)
        prefix = re.match(r'^(.*?)[\d\W_]*$', prefix).group(1)
        
        if len(prefix) < 3:
            # Fallback to the clean name of the anchor
            prefix = clean_name(group[0].path.stem)
            
        # Ensure it looks nice
        prefix = prefix.strip().replace(' ', '_')
        if not prefix: prefix = "Group"
        
        gid = f"{prefix}_Collection"
        for f in group:
            f.group_id = gid

# ==============================================================================
# 6. Rule Engine
# ==============================================================================
# (Rules remain same, re-including for completeness of replace block alignment if needed, 
# but simply skipping lines in replace call is better. I will limit the replace block.)
# Skipping to Executor...

# ... [SKIPPED SECTIONS] ...

# ==============================================================================
# 8. Executor
# ==============================================================================

def execute_moves(plans: List[Tuple[Path, Path]], dry_run: bool = True, tree_view: bool = False,
                  copy_mode: bool = False, copy_base: Optional[Path] = None, 
                  show_progress: bool = False) -> List[Dict]:
    """
    Executes the planned moves or copies.
    Creates directories lazily and logs operations.
    
    Args:
        plans (List[Tuple[Path, Path]]): The move/copy operations plan.
        dry_run (bool): If True, only prints actions without moving files.
        tree_view (bool): If True and dry_run, displays output as a tree.
        copy_mode (bool): If True, copy files instead of moving them.
        copy_base (Optional[Path]): Base directory for copy mode (creates 'warpclean' subdirectory).
        show_progress (bool): Show progress bar if Rich is available.
        
    Returns:
        List[Dict]: A log of executed operations.
    """
    logs = []
    
    if not plans:
        return []

    if dry_run:
        if tree_view:
            # Tree View Logic
            try:
                root = ui.create_tree(f"[bold]Simulation Result[/bold] ({len(plans)} files)")
                
                # We need to map folder paths to tree nodes to prevent duplicates
                # Key: Path tuple (part1, part2), Value: Tree Node
                folder_nodes = {}
                
                # Helper for safe tree display
                def truncate(text: str, limit: int = 35, middle: bool = False) -> str:
                    if len(text) <= limit:
                        return text
                    
                    if middle:
                        # Keep end chars to show extension and potential suffixes
                        # limit 35 -> 16 + 3 + 16 approx
                        half = (limit - 3) // 2
                        return text[:half] + "..." + text[-half:]
                    else:
                        return text[:limit-3] + "..."
                    
                for src, dst in plans:
                    # ... [existing logic for calculating paths] ...
                    
                    # Heuristic: Take the relative structure of the destination's PARENT.
                    # If dst is "D:/.../Documents/a.txt", we want "Documents" -> "a.txt".
                    
                    parent_parts = dst.parent.parts
                    filename = dst.name
                    
                    # Finding common prefix of all destinations to root the tree
                    all_dsts = [p[1] for p in plans]
                    common = os.path.commonpath(all_dsts)
                    if not common:
                        # Fallback to parent dir name
                        relative_parts = [dst.parent.name]
                    else:
                        try:
                            # relative from common root
                            rel = dst.relative_to(common)
                            relative_parts = list(rel.parts)[:-1] # exclude filename
                            if not relative_parts: # file is at root of common
                                relative_parts = ["."] 
                        except ValueError:
                            relative_parts = [dst.parent.name]

                    # Build/Find Path Nodes
                    current_node = root
                    current_path_key = ()
                    
                    for part in relative_parts:
                        if part == ".": continue
                        next_key = current_path_key + (part,)
                        if next_key not in folder_nodes:
                            # Create new folder node
                            # Color folders
                            label = f"[bold blue]{truncate(part, 30)}[/bold blue]"
                            folder_nodes[next_key] = current_node.add(label)
                        
                        current_node = folder_nodes[next_key]
                        current_path_key = next_key
                        
                    # Add File Node
                    # Color based on action? Or just name.
                    src_display = truncate(src.name, 35, middle=True)
                    dst_display = truncate(filename, 35, middle=True)
                    
                    if src.name != filename:
                        # Check if it's just a collision rename (suffix _N)
                        # src: file.txt, dst: file_1.txt
                        is_collision = False
                        try:
                            src_stem = src.stem
                            dst_stem = Path(filename).stem
                            if src.suffix == Path(filename).suffix:
                                # regex: ^src_stem_\d+$
                                import re
                                if re.match(r"^" + re.escape(src_stem) + r"_\d+$", dst_stem):
                                    is_collision = True
                        except Exception:
                            pass
                            
                        # Only show arrow if it is NOT a simple collision rename
                        if not is_collision:
                             label = f"[green]{dst_display}[/green]  [dim](<- {src_display})[/dim]"
                        else:
                             # It is a collision, but user said "only show arrows if there are collisions"
                             # WAIT. User said: "arrow only appears if there are collisions"
                             # AND "show what file came from where".
                             # But typical "file_1" is obvious?
                             # Previous request: "why is there arrows here still ?" for _1 files.
                             # So user WANTS TO HIDE arrow for _1 files.
                             label = f"[green]{dst_display}[/green]"
                    else:
                        label = f"[green]{dst_display}[/green]"
                        
                    current_node.add(label)
                    
                    # Add log entry
                    entry = {
                        "src": str(src),
                        "dst": str(dst),
                        "timestamp": datetime.now().isoformat(),
                        "status": "dry-run"
                    }
                    logs.append(entry)

                print()
                ui.print(root)
                print()
                return logs
                
            except Exception as e:
                print(f"Tree view failed: {e}. Falling back to table.")
                # Fallthrough to table logic

        # Beautiful Table Output using Rich
        try:
            table = ui.create_table(title=f"Dry Run Simulation ({len(plans)} files)")
            
            # Rich Table formatting
            try:
                table.add_column("Source File", style="cyan")
                table.add_column("Action", justify="center", style="bold red")
                table.add_column("Destination", style="green")
            except TypeError:
                pass
                
            for src, dst in plans:
                # Truncate source name
                src_name = src.name
                if len(src_name) > 40:
                    src_name = src_name[:37] + "..."
                    
                # Show relative destination
                try:
                    dst_str = f"{dst.parent.name}/{dst.name}"
                    if len(dst_str) > 40:
                        dst_str = dst_str[:37] + "..."
                except Exception:
                    dst_str = dst.name

                table.add_row(src_name, "->", dst_str)
                
                entry = {
                    "src": str(src),
                    "dst": str(dst),
                    "timestamp": datetime.now().isoformat(),
                    "status": "dry-run"
                }
                logs.append(entry)
                
            print()
            ui.print(table)
            print()
            return logs
        except Exception as e:
            # Fallback
            print(f"Dry Run: {len(plans)} files planned. (Error: {e})")

    # Execution 
    # Adjust destination paths for copy mode
    if copy_mode and copy_base:
        warpclean_dir = copy_base / "warpclean"
        adjusted_plans = []
        for src, dst in plans:
            # Make destination relative to warpclean directory
            # If dst is already relative to base, keep structure; otherwise make it relative
            try:
                # Try to make path relative to base
                if dst.is_absolute() and copy_base in dst.parents:
                    rel_path = dst.relative_to(copy_base)
                else:
                    # Use the destination structure as-is but under warpclean
                    rel_path = Path(*dst.parts[1:]) if len(dst.parts) > 1 else dst.name
                new_dst = warpclean_dir / rel_path
            except Exception:
                # Fallback: use destination name directly
                new_dst = warpclean_dir / dst.name
            adjusted_plans.append((src, new_dst))
        plans = adjusted_plans
    
    # Pre-create directories
    target_dirs = {dst.parent for _, dst in plans}
    for d in target_dirs:
        try:
            d.mkdir(parents=True, exist_ok=True)
        except Exception as e:
            ui.print(f"[red]WARN[/red] Failed to create dir {d}: {e}")

    # Execute with progress bar if requested
    if show_progress and hasattr(ui, 'create_progress') and not dry_run:
        try:
            with ui.create_progress(
                ui.SpinnerColumn(),
                ui.TextColumn("[progress.description]{task.description}"),
                ui.BarColumn(),
                ui.TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
                ui.TimeElapsedColumn(),
            ) as progress:
                task = progress.add_task(
                    f"{'Copying' if copy_mode else 'Moving'} files...", 
                    total=len(plans)
                )
                for src, dst in plans:
                    entry = {
                        "src": str(src),
                        "dst": str(dst),
                        "timestamp": datetime.now().isoformat(),
                        "status": "pending",
                        "operation": "copy" if copy_mode else "move"
                    }
                    
                    try:
                        if dst.exists():
                            entry["status"] = "skipped_exists"
                        else:
                            if copy_mode:
                                shutil.copy2(str(src), str(dst))
                                entry["status"] = "success"
                            else:
                                shutil.move(str(src), str(dst))
                                entry["status"] = "success"
                    except Exception as e:
                        entry["status"] = "error"
                        entry["error"] = str(e)
                    
                    logs.append(entry)
                    progress.update(task, advance=1)
        except Exception:
            # Fallback to non-progress version
            pass
    
    # Non-progress execution (fallback or when progress not requested)
    if not (show_progress and hasattr(ui, 'create_progress') and not dry_run):
        for src, dst in plans:
            entry = {
                "src": str(src),
                "dst": str(dst),
                "timestamp": datetime.now().isoformat(),
                "status": "pending",
                "operation": "copy" if copy_mode else "move"
            }
                
            try:
                if dst.exists():
                    ui.print(f"[yellow][SKIP][/yellow] Target exists: {dst.name}")
                    entry["status"] = "skipped_exists"
                else:
                    if copy_mode:
                        shutil.copy2(str(src), str(dst))
                        ui.print(f"[green][OK][/green] Copied: {src.name} -> {dst.parent.name}/{dst.name}")
                    else:
                        shutil.move(str(src), str(dst))
                        ui.print(f"[green][OK][/green] Moved: {src.name} -> {dst.parent.name}/{dst.name}")
                    entry["status"] = "success"
                    
            except Exception as e:
                ui.print(f"[red][bold][ERROR][/bold][/red] Failed {'copying' if copy_mode else 'moving'} {src.name}: {e}")
                entry["status"] = "error"
                entry["error"] = str(e)
                
            logs.append(entry)
        
    return logs

# ==============================================================================
# 6. Rule Engine
# ==============================================================================

Rule = Callable[[FileInfo], Optional[Path]]


def rule_images(f: FileInfo) -> Optional[Path]:
    if f.category == 'image':
        return Path('Pictures')
    return None

def rule_videos(f: FileInfo) -> Optional[Path]:
    if f.category == 'video':
        return Path('Videos')
    return None

def rule_audio(f: FileInfo) -> Optional[Path]:
    if f.category == 'audio':
        return Path('Audio')
    return None

def rule_documents(f: FileInfo) -> Optional[Path]:
    if f.category == 'document':
        return Path('Documents')
    return None

def rule_archives(f: FileInfo) -> Optional[Path]:
    if f.category == 'archive':
        return Path('Archives')
    return None

def rule_executables(f: FileInfo) -> Optional[Path]:
    if f.category == 'executable':
        return Path('Installers')
    return None

def rule_misc(f: FileInfo) -> Optional[Path]:
    if f.category == 'other':
        return Path('Misc')
    return None

def rule_date_based(f: FileInfo, base_category: Optional[str] = None) -> Optional[Path]:
    """
    Organizes files by date (year/month/day structure).
    Uses modification time or creation time.
    
    Args:
        f (FileInfo): File information.
        base_category (Optional[str]): Base category folder name (e.g., 'Pictures', 'Videos').
                                       If None, uses the file's category.
    
    Returns:
        Optional[Path]: Date-based path or None if date is invalid.
    """
    # Use modification time (more reliable for photos/videos)
    timestamp = f.mtime if f.mtime > 0 else f.ctime
    
    if timestamp <= 0:
        return None
    
    try:
        dt = datetime.fromtimestamp(timestamp)
        year = dt.strftime("%Y")
        month = dt.strftime("%m")
        day = dt.strftime("%d")
        
        # Determine base folder
        if base_category:
            base = Path(base_category)
        else:
            # Map category to folder name
            category_map = {
                'image': 'Pictures',
                'video': 'Videos',
                'audio': 'Audio',
                'document': 'Documents',
                'archive': 'Archives',
                'executable': 'Installers',
                'other': 'Misc'
            }
            base = Path(category_map.get(f.category, 'Misc'))
        
        # Return path: Category/YYYY/MM/DD
        return base / year / month / day
    except Exception:
        return None

def get_default_rules(use_date_based: bool = False) -> List[Rule]:
    """
    Returns the list of default rules for organizing files.
    Order matters: first match wins.
    
    Args:
        use_date_based (bool): If True, use date-based organization for images/videos.
    
    Returns:
        List[Rule]: A list of callable rules.
    """
    rules = []
    
    if use_date_based:
        # Date-based rules for images and videos
        def rule_images_date(f: FileInfo) -> Optional[Path]:
            if f.category == 'image':
                return rule_date_based(f, 'Pictures')
            return None
        
        def rule_videos_date(f: FileInfo) -> Optional[Path]:
            if f.category == 'video':
                return rule_date_based(f, 'Videos')
            return None
        
        rules.extend([rule_images_date, rule_videos_date])
    else:
        rules.extend([rule_images, rule_videos])
    
    # Other rules remain the same
    rules.extend([
        rule_audio,
        rule_documents,
        rule_archives,
        rule_executables,
        rule_misc
    ])
    
    return rules

def apply_rules(file_info: FileInfo, rules: List[Rule]) -> Optional[Path]:
    """
    Applies rules to determine the destination of a file.
    First match wins.
    
    Args:
        file_info (FileInfo): The file metadata.
        rules (List[Rule]): The list of rules to check.
        
    Returns:
        Optional[Path]: The target relative path directory, or None if no rule matches.
    """
    for rule in rules:
        result = rule(file_info)
        if result:
            return result
    return None

# ==============================================================================
# 7. Planner
# ==============================================================================

def plan_moves(files: List[FileInfo], base_dir: Path, use_date_based: bool = False) -> List[Tuple[Path, Path]]:
    """
    Generates a plan of move operations.
    Handles grouping and name conflicts.
    
    Args:
        files (List[FileInfo]): The list of processed files.
        base_dir (Path): The base directory for destinations.
        use_date_based (bool): Use date-based organization for images/videos.
        
    Returns:
        List[Tuple[Path, Path]]: A list of (source, destination) tuples.
    """
    moves = []
    # Track reserved destinations to handle conflicts within the batch
    reserved_paths: Dict[Path, int] = {}
    
    default_rules = get_default_rules(use_date_based=use_date_based)
    
    for f in files:
        # Skip duplicates
        if f.is_duplicate:
            continue
            
        # Determine destination
        if f.group_id:
            # GROUPS override default categorization to keep related files together.
            # They go to root_dir/Group_ID/file.ext
            final_target_dir = base_dir / f.group_id
        else:
            # Apply standard rules
            rule_target = f.target
            if not rule_target:
                rule_target = apply_rules(f, default_rules)
                
            if rule_target:
                final_target_dir = base_dir / rule_target
            else:
                # No rule matched, skip file
                continue
            
        # 3. Determine potential destination path
        #    We want to preserve the original filename unless collision
        original_name = f.path.name
        candidate_path = final_target_dir / original_name
        
        # 4. Conflict Resolution
        #    Must check against filesystem AND reserved_paths
        
        # Helper to check availability
        def is_available(p: Path) -> bool:
            return not p.exists() and p not in reserved_paths
            
        if is_available(candidate_path):
            final_path = candidate_path
        else:
            # Collision detected. Try suffixes.
            stem = f.path.stem
            suffix = f.path.suffix
            counter = 1
            while True:
                new_name = f"{stem}_{counter}{suffix}"
                new_path = final_target_dir / new_name
                if is_available(new_path):
                    final_path = new_path
                    break
                counter += 1
                
        # 5. Check if source == destination (no move needed)
        if f.path.resolve() == final_path.resolve():
            continue
            
        # Record move
        reserved_paths[final_path] = 1 # Mark as taken
        moves.append((f.path, final_path))
        
    return moves



# ==============================================================================
# 9. Undo System
# ==============================================================================

try:
    import orjson
    HAS_ORJSON = True
    def json_dumps(obj):
        # orjson returns bytes, decode to str for consistency with json.dumps in text mode file write
        return orjson.dumps(obj).decode('utf-8')
    def json_loads(obj):
        return orjson.loads(obj)
except ImportError:
    HAS_ORJSON = False
    def json_dumps(obj):
        return json.dumps(obj)
    def json_loads(obj):
        return json.loads(obj)



def save_undo_log(log_file: Path, entries: List[Dict]) -> None:
    """
    Appends successful move operations to the JSONL log file.
    Adds a batch marker to track operation batches for multiple undos.
    
    Args:
        log_file (Path): Path to the log file.
        entries (List[Dict]): List of operation logs.
    """
    try:
        with open(log_file, 'a', encoding='utf-8') as f:
            # Add batch start marker
            batch_marker = {
                "type": "batch_start",
                "timestamp": datetime.now().isoformat(),
                "batch_id": datetime.now().strftime("%Y%m%d_%H%M%S_%f")
            }
            f.write(json_dumps(batch_marker) + "\n")
            
            for entry in entries:
                if entry['status'] == 'success':
                    # Use optimized json dumper
                    f.write(json_dumps(entry) + "\n")
            
            # Add batch end marker
            batch_end = {
                "type": "batch_end",
                "batch_id": batch_marker["batch_id"],
                "count": len([e for e in entries if e['status'] == 'success'])
            }
            f.write(json_dumps(batch_end) + "\n")
    except Exception as e:
        print(f"[ERROR] Failed to write undo log: {e}")

def undo_operations(log_file: Path, count: int = 1) -> None:
    """
    Reads the operation log and reverses moves from the last N batches.
    
    Args:
        log_file (Path): Path to the log file.
        count (int): Number of batches to undo (default: 1). Use 0 to undo all batches.
    """
    if not log_file.exists():
        print("No undo log found.")
        return

    print(f"Undoing {count if count > 0 else 'all'} batch(es) from {log_file}...")
    
    # Read all log entries
    all_entries = []
    try:
        with open(log_file, 'r', encoding='utf-8') as f:
            for line in f:
                if not line.strip():
                    continue
                try:
                    entry = json_loads(line)
                    all_entries.append(entry)
                except Exception:
                    continue
    except Exception as e:
        print(f"[ERROR] Failed reading undo log: {e}")
        return
    
    if not all_entries:
        print("No operations found in log file.")
        return
    
    # Identify batches by finding batch_start and batch_end markers
    batches = []
    current_batch = []
    in_batch = False
    
    for entry in all_entries:
        if entry.get('type') == 'batch_start':
            if in_batch and current_batch:
                # Close previous batch if it wasn't properly closed
                batches.append(current_batch)
            current_batch = [entry]
            in_batch = True
        elif entry.get('type') == 'batch_end':
            if in_batch:
                current_batch.append(entry)
                batches.append(current_batch)
                current_batch = []
                in_batch = False
        elif in_batch:
            current_batch.append(entry)
    
    # Handle unclosed batch
    if in_batch and current_batch:
        batches.append(current_batch)
    
    if not batches:
        print("No complete batches found. Attempting to undo all operations...")
        # Fallback: treat all entries as one batch
        batches = [all_entries]
    
    # Determine how many batches to undo
    if count <= 0:
        batches_to_undo = batches
    else:
        batches_to_undo = batches[-count:] if count <= len(batches) else batches
    
    if not batches_to_undo:
        print("No batches to undo.")
        return
    
    print(f"Found {len(batches)} batch(es) in log. Undoing last {len(batches_to_undo)} batch(es)...")
    
    # Collect all operations to undo (in reverse order within each batch)
    undo_plan = []
    entries_to_remove = []
    
    for batch in batches_to_undo:
        for entry in reversed(batch):
            if entry.get('type') in ('batch_start', 'batch_end'):
                entries_to_remove.append(entry)
                continue
            
            # Regular move operation
            if 'src' in entry and 'dst' in entry:
                src = Path(entry['src'])
                dst = Path(entry['dst'])
                
                # Reverse: move dst back to src
                if dst.exists():
                    undo_plan.append((dst, src))
                    entries_to_remove.append(entry)
                else:
                    print(f"[SKIP] Cannot undo {dst} -> {src}: File not found")
                    entries_to_remove.append(entry)
    
    if not undo_plan:
        print("No operations to undo (files may have been moved or deleted).")
        # Still remove entries from log
        _remove_entries_from_log(log_file, entries_to_remove)
        return

    # Execute Undo
    directories_to_clean = set()
    success_count = 0
    
    for src, dst in undo_plan:
        try:
            if not dst.parent.exists():
                dst.parent.mkdir(parents=True, exist_ok=True)
                
            if dst.exists():
                print(f"[SKIP] Target exists: {dst}")
            else:
                shutil.move(str(src), str(dst))
                print(f"[UNDO] Restored: {src.name} -> {dst}")
                success_count += 1
                # Track the directory where the file was (src.parent) to check for cleanup later
                directories_to_clean.add(src.parent)
                
        except Exception as e:
            print(f"[ERROR] Failed undoing {src.name}: {e}")
    
    print(f"\n[UNDO] Successfully restored {success_count} file(s).")
            
    # Cleanup empty directories
    for directory in directories_to_clean:
        try:
            # os.rmdir only deletes if empty
            if directory.exists():
                directory.rmdir() 
                print(f"[CLEAN] Removed empty dir: {directory}")
        except OSError:
            # Directory not empty or other error
            pass
    
    # Remove undone entries from log file
    _remove_entries_from_log(log_file, entries_to_remove)
    print(f"Undo complete. Removed {len(entries_to_remove)} entries from log.")


def _remove_entries_from_log(log_file: Path, entries_to_remove: List[Dict]) -> None:
    """
    Removes specified entries from the log file.
    
    Args:
        log_file (Path): Path to the log file.
        entries_to_remove (List[Dict]): List of entries to remove.
    """
    try:
        # Read all entries
        all_entries = []
        with open(log_file, 'r', encoding='utf-8') as f:
            for line in f:
                if not line.strip():
                    continue
                try:
                    entry = json_loads(line)
                    all_entries.append((line.strip(), entry))
                except Exception:
                    continue
        
        # Create a helper function to generate a unique key for an entry
        def get_entry_key(entry: Dict) -> Optional[str]:
            """Generate a unique key for an entry based on its identifying fields."""
            if 'type' in entry:
                # For batch markers, use type and batch_id
                if 'batch_id' in entry:
                    return f"batch:{entry['type']}:{entry['batch_id']}"
                # For batch_end without batch_id, use type and count
                if 'count' in entry:
                    return f"batch:{entry['type']}:count:{entry['count']}"
                return f"batch:{entry['type']}"
            elif 'src' in entry and 'dst' in entry:
                # For move operations, use src and dst paths
                return f"move:{entry['src']}:{entry['dst']}"
            return None
        
        # Create a set of keys for entries to remove
        entries_to_remove_set = set()
        for entry in entries_to_remove:
            key = get_entry_key(entry)
            if key:
                entries_to_remove_set.add(key)
        
        # Filter entries
        remaining_entries = []
        for line, entry in all_entries:
            key = get_entry_key(entry)
            if key and key in entries_to_remove_set:
                # Skip this entry (it's marked for removal)
                continue
            remaining_entries.append(line)
        
        # Write back remaining entries
        if remaining_entries:
            with open(log_file, 'w', encoding='utf-8') as f:
                for line in remaining_entries:
                    f.write(line + "\n")
        else:
            # Log file is empty, remove it
            log_file.unlink()
            
    except Exception as e:
        print(f"[WARN] Failed to update log file: {e}")
        # Create backup before failing
        try:
            backup_log = log_file.with_suffix('.jsonl.bak')
            shutil.copy2(log_file, backup_log)
            print(f"[INFO] Log file backed up to {backup_log}")
        except Exception:
            pass

# ==============================================================================
# 10. CLI
# ==============================================================================

def cleanup_empty_dirs_recursive(path: Path) -> None:
    """
    Recursively removes empty directories.
    """
    if not path.is_dir():
        return

    # Go deep first (bottom-up)
    for child in path.iterdir():
        if child.is_dir():
            cleanup_empty_dirs_recursive(child)

    # Check if empty now
    try:
        # rmdir only removes if empty
        path.rmdir()
        logging.info(f"[CLEAN] Removed empty dir: {path}")
    except OSError:
        pass # Not empty or permission error

# ... (main function updates below) ...

def print_rich_help():
    """Print beautifully formatted help using Rich."""
    try:
        from rich.console import Console
        from rich.panel import Panel
        from rich.text import Text
        from rich.table import Table
        from rich import box
        
        console = Console()
        
        # Title
        title = Text("WARPCLEAN", style="bold bright_cyan")
        subtitle = Text("Blazing Fast Python CLI File Organizer", style="dim")
        console.print()
        console.print(Panel.fit(
            f"{title}\n{subtitle}",
            border_style="bright_cyan",
            padding=(1, 2)
        ))
        console.print()
        
        # Commands section
        console.print("[bold bright_yellow]COMMANDS & USAGE:[/bold bright_yellow]")
        console.print()
        
        commands = [
            ("[bold cyan]1. Basic Organization[/bold cyan]", 
             "python warpclean.py /path/to/folder",
             "Moves files into Categories: [green]Pictures[/green], [green]Videos[/green], [green]Audio[/green], [green]Documents[/green], [green]Archives[/green], [green]Installers[/green], [green]Misc[/green]."),
            ("[bold cyan]2. Dry Run (Simulation)[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--dry-run[/yellow]",
             "Shows what [yellow]WOULD[/yellow] happen without moving any files."),
            ("[bold cyan]3. Group Related Files[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--group-related[/yellow]",
             "Groups sequences ([dim]img_01.jpg, img_02.jpg[/dim]) and fuzzy matches ([dim]report_v1, report_final[/dim]) into a dedicated Collection folder."),
            ("[bold cyan]4. Blazing Fast Mode[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--fast[/yellow]",
             "Skips content analysis (magic bytes) and trusts file extensions. Use this for massive directories."),
            ("[bold cyan]5. Undo Operations[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--undo[/yellow] [dim][N][/dim]",
             "Reverses the last batch of moves (default: 1). Use [yellow]--undo 3[/yellow] for 3 batches, [yellow]--undo 0[/yellow] for all."),
            ("[bold cyan]6. Cleanup Empty Dirs[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--clean-empty-dirs[/yellow]",
             "Recursively removes empty directories after organizing."),
            ("[bold cyan]7. Copy Mode[/bold cyan] [dim](Organize without moving originals)[/dim]",
             "python warpclean.py /path/to/folder [yellow]--copy[/yellow]",
             "Copies files to a [green]'warpclean'[/green] subdirectory instead of moving them. Keeps originals in place."),
            ("[bold cyan]8. Duplicate Detection[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--detect-duplicates[/yellow]",
             "Detects and skips duplicate files using [dim]MD5 hash[/dim] comparison. Only first occurrence is organized."),
            ("[bold cyan]9. Date-Based Organization[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--date-based[/yellow]",
             "Organizes images/videos by date ([green]YYYY/MM/DD[/green] structure). Example: [dim]Pictures/2024/01/15/photo.jpg[/dim]"),
            ("[bold cyan]10. Progress Bars[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--progress[/yellow]",
             "Shows visual progress bars during operations. Requires [dim]Rich[/dim] library."),
            ("[bold cyan]11. Tree View[/bold cyan] [dim](Dry Run)[/dim]",
             "python warpclean.py /path/to/folder [yellow]--tree --dry-run[/yellow]",
             "Displays dry run results as a tree diagram instead of a table."),
            ("[bold cyan]12. Colored Output[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--color[/yellow]",
             "Enables colored output using [dim]Rich[/dim] library."),
            ("[bold cyan]13. Verbose Logging[/bold cyan]",
             "python warpclean.py /path/to/folder [yellow]--verbose[/yellow]",
             "Enables detailed debug logging."),
        ]
        
        for i, (title_text, command, desc) in enumerate(commands, 1):
            console.print(f"  {title_text}")
            console.print(f"    [dim]{command}[/dim]")
            console.print(f"    → {desc}")
            console.print()
        
        # Examples section
        console.print("[bold bright_yellow]FLAG COMBINATIONS & EXAMPLES:[/bold bright_yellow]")
        console.print()
        
        examples = [
            ("Basic organization", "python warpclean.py C:\\Downloads"),
            ("Safe preview", "python warpclean.py C:\\Downloads [yellow]--dry-run --fast[/yellow]"),
            ("Organize photos", "python warpclean.py C:\\Photos [yellow]--date-based --copy --detect-duplicates[/yellow]"),
            ("Full featured", "python warpclean.py C:\\Downloads [yellow]--copy --date-based --detect-duplicates --progress --group-related[/yellow]"),
            ("Quick organize", "python warpclean.py C:\\Files [yellow]--fast --copy --progress[/yellow]"),
            ("Camera photos", "python warpclean.py C:\\Camera [yellow]--copy --date-based --detect-duplicates --progress[/yellow]"),
            ("Preview with tree", "python warpclean.py C:\\Photos [yellow]--dry-run --tree --date-based[/yellow]"),
        ]
        
        for desc, cmd in examples:
            console.print(f"  [bold]{desc}:[/bold]")
            console.print(f"    [dim]{cmd}[/dim]")
            console.print()
        
        # Options table
        console.print("[bold bright_yellow]ALL OPTIONS:[/bold bright_yellow]")
        console.print()
        
        table = Table(show_header=True, header_style="bold bright_cyan", box=box.ROUNDED)
        table.add_column("Option", style="cyan", no_wrap=True)
        table.add_column("Description", style="white")
        
        options = [
            ("[yellow]--dry-run[/yellow]", "Simulate moves without executing"),
            ("[yellow]--group-related[/yellow]", "Group related files together"),
            ("[yellow]--undo [N][/yellow]", "Undo last N batches (default: 1, 0 = all)"),
            ("[yellow]--fast[/yellow]", "Skip content-based detection (trust extensions)"),
            ("[yellow]--clean-empty-dirs[/yellow]", "Remove empty directories after organizing"),
            ("[yellow]--copy[/yellow]", "Copy files to 'warpclean' subdirectory instead of moving"),
            ("[yellow]--detect-duplicates[/yellow]", "Detect and skip duplicate files"),
            ("[yellow]--date-based[/yellow]", "Organize images/videos by date (YYYY/MM/DD)"),
            ("[yellow]--progress[/yellow]", "Show progress bars (requires rich)"),
            ("[yellow]--verbose, -v[/yellow]", "Enable verbose logging"),
            ("[yellow]--color[/yellow]", "Enable colored output (requires rich)"),
            ("[yellow]--tree[/yellow]", "Show dry run as a tree diagram"),
        ]
        
        for opt, desc in options:
            table.add_row(opt, desc)
        
        console.print(table)
        console.print()
        
    except ImportError:
        # Fallback to plain text if Rich not available
        import sys
        parser = argparse.ArgumentParser()
        parser.print_help()

def main():
    """
    Main entry point for the CLI.
    Parses arguments and orchestrates the pipeline.
    """
    # Check for help flag first and show rich help
    # This must be done before argparse processes arguments
    if len(sys.argv) > 1 and (sys.argv[1] in ['--help', '-h', 'help']):
        print_rich_help()
        sys.exit(0)
    
    description = """
WARPCLEAN: Blazing Fast Python CLI File Organizer

COMMANDS & USAGE:
------------------
1. Basic Organization (Safe Move):
   python warpclean.py /path/to/folder
   -> Moves files into Categories: Pictures, Videos, Audio, Documents, Archives, Installers, Misc.

2. Dry Run (Simulation):
   python warpclean.py /path/to/folder --dry-run
   -> Shows what WOULD happen without moving any files.

3. Group Related Files:
   python warpclean.py /path/to/folder --group-related
   -> Groups sequences (img_01.jpg, img_02.jpg) and fuzzy matches (report_v1, report_final) 
      into a dedicated Collection folder at the root.

4. Blazing Fast Mode:
   python warpclean.py /path/to/folder --fast
   -> Skips content analysis (magic bytes) and trusts file extensions. 
      Use this for massive directories.

5. Undo Operations:
   python warpclean.py /path/to/folder --undo
   -> Reverses the last batch of moves (default: 1 batch).
   
   python warpclean.py /path/to/folder --undo 3
   -> Reverses the last 3 batches of moves.
   
   python warpclean.py /path/to/folder --undo 0
   -> Reverses all batches in the log file.
   
6. Cleanup Empty Dirs:
   python warpclean.py /path/to/folder --clean-empty-dirs
   -> Recursively removes empty directories after organizing.
   
7. Copy Mode (Organize without moving originals):
   python warpclean.py /path/to/folder --copy
   -> Copies files to a 'warpclean' subdirectory instead of moving them.
      Keeps originals in place, organized copies in warpclean/ folder.
      Perfect for organizing without losing original file locations.
   
8. Duplicate Detection:
   python warpclean.py /path/to/folder --detect-duplicates
   -> Detects and skips duplicate files using MD5 hash comparison.
      Only the first occurrence is organized, duplicates are skipped.
      Prevents organizing the same file multiple times.
   
9. Date-Based Organization:
   python warpclean.py /path/to/folder --date-based
   -> Organizes images and videos by date (YYYY/MM/DD folder structure).
      Uses file modification time to create date-based folders.
      Example: Pictures/2024/01/15/photo.jpg
      Useful for photos and videos from cameras or screenshots.
   
10. Progress Bars:
   python warpclean.py /path/to/folder --progress
   -> Shows visual progress bars during scanning, classification, and execution.
      Requires Rich library (pip install rich).
      Provides real-time feedback on operation progress.

11. Tree View (Dry Run):
   python warpclean.py /path/to/folder --tree --dry-run
   -> Displays dry run results as a tree diagram instead of a table.
      Shows organized folder structure visually.

12. Colored Output:
   python warpclean.py /path/to/folder --color
   -> Enables colored output using Rich library.
      Makes output more readable with syntax highlighting.

13. Verbose Logging:
   python warpclean.py /path/to/folder --verbose
   -> Enables detailed debug logging.
      Shows additional information about the organization process.

FLAG COMBINATIONS & EXAMPLES:
----------------------------
Basic organization:
   python warpclean.py C:\\Downloads

Safe preview (dry run):
   python warpclean.py C:\\Downloads --dry-run --fast

Organize photos with date-based folders:
   python warpclean.py C:\\Photos --date-based --copy --detect-duplicates

Full featured organization with progress:
   python warpclean.py C:\\Downloads --copy --date-based --detect-duplicates --progress --group-related

Quick organization with all optimizations:
   python warpclean.py C:\\Files --fast --copy --progress

Camera photos organization:
   python warpclean.py C:\\Camera --copy --date-based --detect-duplicates --progress

Preview with tree view:
   python warpclean.py C:\\Photos --dry-run --tree --date-based
    """
    
    parser = argparse.ArgumentParser(
        description=description, 
        formatter_class=argparse.RawTextHelpFormatter,
        add_help=False  # We handle help ourselves
    )
    parser.add_argument("path", type=Path, help="Directory to clean")
    parser.add_argument("--dry-run", action="store_true", help="Simulate moves without executing")
    parser.add_argument("--group-related", action="store_true", help="Group related files together")
    parser.add_argument("--undo", type=int, nargs='?', const=1, metavar='N', 
                        help="Undo last N operation batches (default: 1). Use --undo 0 to undo all batches.")
    parser.add_argument("--fast", action="store_true", help="Fast mode: skip content-based detection (trust extensions)")
    parser.add_argument("--clean-empty-dirs", action="store_true", help="Remove empty directories after organizing")
    parser.add_argument("--copy", action="store_true", help="Copy files to 'warpclean' subdirectory instead of moving")
    parser.add_argument("--detect-duplicates", action="store_true", help="Detect and skip duplicate files")
    parser.add_argument("--date-based", action="store_true", help="Organize images/videos by date (YYYY/MM/DD)")
    parser.add_argument("--progress", action="store_true", help="Show progress bars (requires rich)")
    parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging")
    parser.add_argument("--color", action="store_true", help="Enable colored output (requires rich)")
    parser.add_argument("--tree", action="store_true", help="Show dry run as a tree diagram")
    parser.add_argument("--help", "-h", action="store_true", help="Show this help message and exit")
    
    # Parse arguments (help is handled by our custom function)
    args = parser.parse_args()
    
    # Check if help was requested
    if args.help:
        print_rich_help()
        sys.exit(0)
    
    if args.color or args.progress:
        enable_rich_output()
    
    # Configure Logging
    level = logging.DEBUG if args.verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format='%(message)s' # Keep it simple for CLI output
    )
    
    root_dir: Path = args.path.resolve()
    undo_log_file = root_dir / ".warpclean_undo.jsonl"
    
    if args.undo is not None:
        undo_count = args.undo if args.undo >= 0 else 1
        undo_operations(undo_log_file, count=undo_count)
        return

    if not root_dir.exists() or not root_dir.is_dir():
        logging.error(f"Error: Directory not found: {root_dir}")
        sys.exit(1)
    
    show_progress = args.progress
    copy_mode = args.copy
    detect_dups = args.detect_duplicates
    date_based = args.date_based
        
    logging.info(f"Propagating warpclean on {root_dir} (Dry Run: {args.dry_run}, Fast Mode: {args.fast}, Copy: {copy_mode}, Date-Based: {date_based}, Duplicates: {detect_dups})")
    
    # 1. Scan
    if not show_progress:
        logging.info("Scanning...")
    paths = scan_directory(root_dir, show_progress=show_progress)
    if not show_progress:
        logging.info(f"Found {len(paths)} files.")
    
    if not paths and not args.clean_empty_dirs:
        logging.info("No files to organize.")
        return
        
    # 2. Classify
    if not show_progress:
        logging.info("Classifying...")
    file_infos = classify_files(paths, fast_mode=args.fast, show_progress=show_progress)
    
    # 3. Detect Duplicates (if requested)
    if detect_dups:
        if not show_progress:
            logging.info("Detecting duplicates...")
        detect_duplicates(file_infos, compute_hashes=True)
        dup_count = sum(1 for f in file_infos if f.is_duplicate)
        if not show_progress:
            logging.info(f"Found {dup_count} duplicate file(s). They will be skipped.")
        else:
            ui.print(f"[yellow]Found {dup_count} duplicate file(s). They will be skipped.[/yellow]")
    
    # 4. Group (Optional)
    if args.group_related:
        if not show_progress:
            logging.info("Grouping related files...")
        group_related_files(file_infos)
        
    # 5. Plan
    if not show_progress:
        logging.info("Planning moves...")
    plans = plan_moves(file_infos, root_dir, use_date_based=date_based)
    
    if not plans and not args.clean_empty_dirs:
        logging.info("No moves necessary. Everything is clean!")
        return
        
    if not show_progress:
        logging.info(f"Planned {len(plans)} moves.")
    
    # 6. Execute
    if not show_progress:
        logging.info("Executing..." if not args.dry_run else "Dry Run Execution:")
    logs = execute_moves(
        plans, 
        dry_run=args.dry_run, 
        tree_view=args.tree,
        copy_mode=copy_mode,
        copy_base=root_dir if copy_mode else None,
        show_progress=show_progress
    )
    
    # 7. Save Log
    if not args.dry_run:
        save_undo_log(undo_log_file, logs)
        
        # 8. Cleanup
        if args.clean_empty_dirs:
            if not show_progress:
                logging.info("Cleaning up empty directories...")
            cleanup_empty_dirs_recursive(root_dir)
        
        # Summary
        success_count = sum(1 for log in logs if log.get('status') == 'success')
        if show_progress:
            ui.print(f"\n[green]✓ Done![/green] {success_count} file(s) {'copied' if copy_mode else 'moved'} successfully.")
        else:
            logging.info(f"Done. {success_count} file(s) {'copied' if copy_mode else 'moved'} successfully.")
    else:
        if not show_progress:
            logging.info("Dry run complete. No files moved.")
        if args.clean_empty_dirs:
            if not show_progress:
                logging.info("[DRY-RUN] Would cleanup empty directories.")

if __name__ == "__main__":
    main()
