"""
Command-line interface for MedTDA.

This module provides a command-line tool for extracting TDA features from
medical images, supporting both single-file and batch processing modes.
"""

import argparse
import os
import pickle
import sys
import traceback
import yaml
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Optional, List, Dict, Any, Tuple

import pandas as pd
from tqdm import tqdm

from .featureextractor import FeatureExtractor


def create_parser() -> argparse.ArgumentParser:
    """
    Create and configure the argument parser.
    
    Returns
    -------
    argparse.ArgumentParser
        Configured argument parser for MedTDA CLI.
    """
    parser = argparse.ArgumentParser(
        prog='medtda',
        description='Extract Topological Data Analysis features from medical images',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Single file processing
  medtda image.nii.gz --output-dir ./results
  medtda image.nii.gz --mask mask.nii.gz --output-dir ./results --normalize
  
  # Using configuration file
  medtda image.nii.gz --config config.yaml --output-dir ./results
  
  # Config with CLI override (CLI args take precedence)
  medtda image.nii.gz --config config.yaml --output-dir ./results --methods persistence_stats
  
  # Batch processing from CSV
  medtda cases.csv --output-dir ./results --methods persistence_stats betti_curve
  
  # Batch processing with parallel workers
  medtda cases.csv --output-dir ./results --workers 4 --verbose
  
  # Batch processing with all available CPU cores
  medtda cases.csv --output-dir ./results -j -1 --methods persistence_stats
  
  # Advanced preprocessing
  medtda image.nii.gz --output-dir ./results \\
      --normalize --normalize-method minmax \\
      --spacing 1.0 1.0 1.0 --crop-roi \\
      --methods persistence_stats entropy_summary \\
      --save-barcodes --verbose

For batch mode, CSV file should have columns: id, image_path, mask_path
(mask_path is optional and can be empty)
        """
    )
    
    # Positional argument - input file (auto-detect single vs batch)
    parser.add_argument(
        'input',
        type=str,
        help='Input image file (single mode) or CSV file (batch mode). '
             'CSV format: id,image_path,mask_path'
    )
    
    # Optional mask for single file mode
    parser.add_argument(
        '--mask',
        type=str,
        default=None,
        help='Mask file path (single file mode only). '
             'For batch mode, specify masks in CSV.'
    )
    
    # Configuration file
    parser.add_argument(
        '-c', '--config',
        type=str,
        default=None,
        help='Path to YAML configuration file. CLI arguments override config values.'
    )
    
    # Output directory (required via CLI or config file)
    parser.add_argument(
        '-o', '--output-dir',
        type=str,
        default=None,
        help='Output directory to save extracted features (required, can be set in config file)'
    )
    
    # Preprocessing arguments
    preproc_group = parser.add_argument_group('preprocessing options')
    
    preproc_group.add_argument(
        '--spacing',
        type=float,
        nargs='+',
        default=None,
        metavar='S',
        help='Target voxel spacing for resampling (e.g., --spacing 1.0 1.0 1.0). '
             'Only for 3D/4D images.'
    )
    
    preproc_group.add_argument(
        '--window',
        type=float,
        nargs=2,
        default=None,
        metavar=('CENTER', 'WIDTH'),
        help='Windowing parameters as center and width (e.g., --window 40 400)'
    )
    
    preproc_group.add_argument(
        '--normalize',
        action='store_true',
        help='Enable intensity normalization'
    )
    
    preproc_group.add_argument(
        '--normalize-method',
        type=str,
        choices=['minmax', 'zscore', 'robust'],
        default='minmax',
        help='Normalization method (default: minmax). Only used if --normalize is set.'
    )
    
    preproc_group.add_argument(
        '--background-value',
        type=float,
        default=None,
        help='Background value for masked regions. If not set, auto-determined.'
    )
    
    preproc_group.add_argument(
        '--label',
        type=int,
        default=1,
        help='Label value to extract from multi-label mask (default: 1). '
             'Use 0 to treat mask as binary.'
    )
    
    preproc_group.add_argument(
        '--crop-roi',
        action='store_true',
        default=True,
        help='Crop to ROI bounding box when mask is provided (default: enabled)'
    )
    
    preproc_group.add_argument(
        '--no-crop-roi',
        action='store_false',
        dest='crop_roi',
        help='Disable ROI cropping'
    )
    
    preproc_group.add_argument(
        '--roi-padding',
        type=int,
        default=1,
        help='Padding around ROI bounding box in pixels (default: 1)'
    )
    
    # Persistent Homology arguments
    ph_group = parser.add_argument_group('persistent homology options')
    
    ph_group.add_argument(
        '--filtration',
        type=str,
        choices=['sublevel', 'superlevel'],
        default='sublevel',
        help='Filtration type (default: sublevel)'
    )
    
    ph_group.add_argument(
        '--construction',
        type=str,
        choices=['T', 'V'],
        default='T',
        help='Cubical complex construction type (default: T)'
    )
    
    ph_group.add_argument(
        '--max-dimension',
        type=int,
        default=-1,
        help='Maximum homology dimension to compute (default: -1 for auto-detect)'
    )
    
    # Vectorization arguments
    vec_group = parser.add_argument_group('vectorization options')
    
    vec_group.add_argument(
        '--methods',
        type=str,
        nargs='+',
        default=['persistence_stats'],
        metavar='METHOD',
        help='Vectorization method(s) to apply. Options: persistence_stats, '
             'betti_curve, persistence_image, persistence_landscape, '
             'entropy_summary, persistence_silhouette, persistence_lifespan, '
             'persistence_tropical_coordinates (default: persistence_stats)'
    )
    
    vec_group.add_argument(
        '--save-barcodes',
        action='store_true',
        help='Save raw persistence barcodes as pickle files'
    )
    
    # Parallelization options
    parallel_group = parser.add_argument_group('parallelization options')
    
    parallel_group.add_argument(
        '-j', '--workers',
        type=int,
        default=1,
        metavar='N',
        help='Number of parallel workers for batch processing (default: 1). '
             'Use -1 to use all available CPU cores. Only applies to batch mode.'
    )
    
    # Logging and output options
    log_group = parser.add_argument_group('logging options')
    
    log_group.add_argument(
        '-v', '--verbose',
        action='store_true',
        help='Enable verbose output'
    )
    
    log_group.add_argument(
        '-q', '--quiet',
        action='store_true',
        help='Suppress all non-error output'
    )
    
    parser.add_argument(
        '--version',
        action='version',
        version='%(prog)s 0.1.0a1'
    )
    
    return parser


def load_config(config_path: str) -> Dict[str, Any]:
    """
    Load configuration from YAML file.
    
    Parameters
    ----------
    config_path : str
        Path to YAML configuration file.
        
    Returns
    -------
    dict
        Configuration dictionary.
        
    Raises
    ------
    ValueError
        If config file is invalid or cannot be loaded.
    """
    config_file = Path(config_path)
    
    if not config_file.exists():
        raise ValueError(f"Configuration file does not exist: {config_path}")
    
    try:
        with open(config_file, 'r') as f:
            config = yaml.safe_load(f)
    except yaml.YAMLError as e:
        raise ValueError(f"Invalid YAML in config file: {e}")
    except Exception as e:
        raise ValueError(f"Failed to load config file: {e}")
    
    if config is None:
        config = {}
    
    if not isinstance(config, dict):
        raise ValueError("Configuration file must contain a YAML dictionary")
    
    return config


def merge_config_with_args(config: Dict[str, Any], args: argparse.Namespace) -> argparse.Namespace:
    """
    Merge YAML configuration with CLI arguments.
    CLI arguments take precedence over config file values.
    
    Parameters
    ----------
    config : dict
        Configuration dictionary from YAML file.
    args : argparse.Namespace
        Parsed command-line arguments.
        
    Returns
    -------
    argparse.Namespace
        Merged arguments with CLI values overriding config values.
    """
    # Extract config sections
    preprocessing = config.get('preprocessing', {})
    ph_config = config.get('persistent_homology', {})
    vectorization = config.get('vectorization', {})
    parallel_config = config.get('parallel', {})
    output_config = config.get('output', {})
    
    # Merge preprocessing options (if not explicitly set via CLI)
    if args.spacing is None and 'spacing' in preprocessing:
        spacing = preprocessing['spacing']
        if spacing is not None:
            args.spacing = spacing if isinstance(spacing, list) else [spacing]
    
    if args.window is None and 'window' in preprocessing:
        window = preprocessing['window']
        if window is not None and len(window) == 2:
            args.window = window
    
    # For boolean flags, only use config if CLI didn't set it
    # Note: argparse sets defaults, so we need to check if user explicitly provided the flag
    # For --normalize, if not provided via CLI, use config value
    if not args.normalize and preprocessing.get('normalize', False):
        args.normalize = True
    
    if args.normalize_method == 'minmax' and 'normalize_method' in preprocessing:
        # Only override if still at default
        args.normalize_method = preprocessing.get('normalize_method', 'minmax')
    
    if args.background_value is None and 'background_value' in preprocessing:
        args.background_value = preprocessing.get('background_value')
    
    if args.label == 1 and 'label' in preprocessing:
        # Only override if still at default
        args.label = preprocessing.get('label', 1)
    
    if args.roi_padding == 1 and 'roi_padding' in preprocessing:
        # Only override if still at default
        args.roi_padding = preprocessing.get('roi_padding', 1)
    
    # Merge persistent homology options
    if args.filtration == 'sublevel' and 'filtration' in ph_config:
        args.filtration = ph_config.get('filtration', 'sublevel')
    
    if args.construction == 'T' and 'construction' in ph_config:
        args.construction = ph_config.get('construction', 'T')
    
    if args.max_dimension == -1 and 'max_dimension' in ph_config:
        args.max_dimension = ph_config.get('max_dimension', -1)
    
    # Merge vectorization options
    if args.methods == ['persistence_stats'] and 'methods' in vectorization:
        methods = vectorization.get('methods', ['persistence_stats'])
        args.methods = methods if isinstance(methods, list) else [methods]
    
    if not args.save_barcodes and vectorization.get('save_barcodes', False):
        args.save_barcodes = True
    
    # Merge parallelization options
    if args.workers == 1 and 'workers' in parallel_config:
        # Only override if still at default
        args.workers = parallel_config.get('workers', 1)
    
    # Merge output options (only if not set via CLI)
    # output_dir is required, so only use config as fallback if not provided
    if args.output_dir is None and 'output_dir' in output_config:
        args.output_dir = output_config.get('output_dir')
    
    if not args.verbose and output_config.get('verbose', False):
        args.verbose = True
    
    if not args.quiet and output_config.get('quiet', False):
        args.quiet = True
    
    return args


def validate_args(args: argparse.Namespace) -> None:
    """
    Validate parsed arguments.
    
    Parameters
    ----------
    args : argparse.Namespace
        Parsed command-line arguments.
        
    Raises
    ------
    ValueError
        If arguments are invalid or incompatible.
    """
    # Check output directory is specified (required)
    if args.output_dir is None:
        raise ValueError("--output-dir is required")
    
    # Check input file exists
    input_path = Path(args.input)
    if not input_path.exists():
        raise ValueError(f"Input file does not exist: {args.input}")
    
    # Determine mode based on file extension
    is_batch_mode = input_path.suffix.lower() == '.csv'
    
    # Validate mask argument (only for single file mode)
    if args.mask is not None and is_batch_mode:
        raise ValueError(
            "The --mask argument is only valid for single file mode. "
            "For batch processing, specify masks in the CSV file."
        )
    
    # Check mask file exists if provided
    if args.mask is not None:
        mask_path = Path(args.mask)
        if not mask_path.exists():
            raise ValueError(f"Mask file does not exist: {args.mask}")
    
    # Validate spacing argument
    if args.spacing is not None:
        if len(args.spacing) not in [2, 3, 4]:
            raise ValueError(
                f"--spacing must have 2, 3, or 4 values (got {len(args.spacing)})"
            )
    
    # Validate window argument
    if args.window is not None:
        center, width = args.window
        if width <= 0:
            raise ValueError(f"Window width must be positive (got {width})")
    
    # Validate label
    if args.label is not None and args.label < 0:
        raise ValueError(f"Label value must be non-negative (got {args.label})")
    
    # Validate ROI padding
    if args.roi_padding < 0:
        raise ValueError(f"ROI padding must be non-negative (got {args.roi_padding})")
    
    # Validate verbosity flags
    if args.verbose and args.quiet:
        raise ValueError("Cannot use both --verbose and --quiet flags")
    
    # Validate workers argument
    if args.workers == 0 or args.workers < -1:
        raise ValueError(
            f"--workers must be positive, or -1 for all CPU cores (got {args.workers})"
        )
    
    # For single file mode, workers should be 1 (no parallelization)
    if not is_batch_mode and args.workers != 1:
        if not args.quiet:
            print(
                "Warning: --workers is ignored in single file mode",
                file=sys.stderr
            )
        args.workers = 1
    
    # Create output directory if it doesn't exist
    output_dir = Path(args.output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    
    # Check if output directory is writable
    if not output_dir.is_dir():
        raise ValueError(f"Output path is not a directory: {args.output_dir}")


def detect_mode(input_path: str) -> str:
    """
    Detect processing mode based on input file extension.
    
    Parameters
    ----------
    input_path : str
        Path to input file.
        
    Returns
    -------
    str
        'batch' if CSV file, 'single' otherwise.
    """
    path = Path(input_path)
    return 'batch' if path.suffix.lower() == '.csv' else 'single'


def save_config(args: argparse.Namespace, output_path: Path) -> None:
    """
    Save configuration to YAML file for reproducibility.
    
    Parameters
    ----------
    args : argparse.Namespace
        Parsed command-line arguments.
    output_path : Path
        Path to save configuration file.
    """
    config = {
        'preprocessing': {
            'normalize': args.normalize,
            'normalize_method': args.normalize_method,
            'spacing': args.spacing,
            'window': list(args.window) if args.window else None,
            'crop_roi': args.crop_roi,
            'roi_padding': args.roi_padding,
            'background_value': args.background_value,
            'label': args.label,
        },
        'persistent_homology': {
            'filtration': args.filtration,
            'construction': args.construction,
            'max_dimension': args.max_dimension,
        },
        'vectorization': {
            'methods': args.methods,
            'save_barcodes': args.save_barcodes,
        },
        'output': {
            'output_dir': str(args.output_dir),
            'verbose': args.verbose,
            'quiet': args.quiet,
        }
    }
    
    with open(output_path, 'w') as f:
        yaml.dump(config, f, default_flow_style=False, sort_keys=False)


def process_single_file(args: argparse.Namespace) -> int:
    """
    Process a single image file.
    
    Parameters
    ----------
    args : argparse.Namespace
        Parsed command-line arguments.
        
    Returns
    -------
    int
        Exit code (0 for success, 1 for failure).
    """
    input_path = Path(args.input)
    output_dir = Path(args.output_dir)
    
    # Get base name for output files (without extension)
    if input_path.name.endswith('.nii.gz'):
        base_name = input_path.name[:-7]  # Remove .nii.gz
    else:
        base_name = input_path.stem
    
    try:
        # Print processing info
        if not args.quiet:
            print("=" * 60)
            print("MedTDA - Single File Processing")
            print("=" * 60)
            print(f"Input image: {input_path}")
            if args.mask:
                print(f"Input mask:  {args.mask}")
            print(f"Output directory: {output_dir}")
            print(f"Vectorization methods: {', '.join(args.methods)}")
            if args.verbose:
                print(f"\nPreprocessing:")
                print(f"  Normalize: {args.normalize}")
                if args.normalize:
                    print(f"  Normalization method: {args.normalize_method}")
                if args.spacing:
                    print(f"  Target spacing: {args.spacing}")
                if args.window:
                    print(f"  Windowing: center={args.window[0]}, width={args.window[1]}")
                print(f"  Crop to ROI: {args.crop_roi}")
                if args.crop_roi:
                    print(f"  ROI padding: {args.roi_padding}")
                if args.background_value is not None:
                    print(f"  Background value: {args.background_value}")
                print(f"\nPersistent Homology:")
                print(f"  Filtration: {args.filtration}")
                print(f"  Construction: {args.construction}")
                print(f"  Max dimension: {args.max_dimension if args.max_dimension >= 0 else 'auto'}")
            print("-" * 60)
        
        # Convert spacing to tuple if provided
        spacing = tuple(args.spacing) if args.spacing else None
        
        # Convert window to tuple if provided
        window = tuple(args.window) if args.window else None
        
        # Initialize FeatureExtractor with configuration
        if args.verbose and not args.quiet:
            print("Initializing feature extractor...")
        
        extractor = FeatureExtractor(
            spacing=spacing,
            window=window,
            normalize=args.normalize,
            normalize_method=args.normalize_method,
            background_value=args.background_value,
            label=args.label,
            crop_to_roi=args.crop_roi,
            roi_padding=args.roi_padding,
            filtration_type=args.filtration,
            construction=args.construction,
            max_dimension=args.max_dimension,
            return_barcodes=args.save_barcodes,
            vectorization_method=args.methods
        )
        
        # Execute feature extraction
        if args.verbose and not args.quiet:
            print(f"Loading image: {input_path}")
            if args.mask:
                print(f"Loading mask: {args.mask}")
            print("Computing persistent homology and extracting features...")
        
        result = extractor.execute(
            image=str(input_path),
            mask=str(args.mask) if args.mask else None
        )
        
        # Handle result (features only or features + barcodes)
        if args.save_barcodes:
            features, barcodes = result  # type: ignore[misc]
        else:
            features = result  # type: ignore[assignment]
            barcodes = None
        
        if args.verbose and not args.quiet:
            print(f"Extracted {len(features)} features")
        
        # Save features to CSV
        features_output = output_dir / f"{base_name}_features.csv"
        features_df = pd.DataFrame([features])
        features_df.to_csv(features_output, index=False)
        
        if not args.quiet:
            print(f"[OK] Features saved: {features_output}")
        
        # Save barcodes if requested
        if args.save_barcodes and barcodes is not None:
            barcodes_output = output_dir / f"{base_name}_barcodes.pkl"
            with open(barcodes_output, 'wb') as f:
                pickle.dump(barcodes, f)
            if not args.quiet:
                print(f"[OK] Barcodes saved: {barcodes_output}")
        
        # Save configuration for reproducibility
        config_output = output_dir / f"{base_name}_config.yaml"
        save_config(args, config_output)
        if args.verbose and not args.quiet:
            print(f"[OK] Configuration saved: {config_output}")
        
        if not args.quiet:
            print("-" * 60)
            print("Processing completed successfully!")
            print("=" * 60)
        
        return 0
        
    except FileNotFoundError as e:
        print(f"Error: File not found - {e}", file=sys.stderr)
        return 1
    except ValueError as e:
        print(f"Error: Invalid input - {e}", file=sys.stderr)
        return 1
    except Exception as e:
        print(f"Error: Processing failed - {e}", file=sys.stderr)
        if args.verbose:
            import traceback
            traceback.print_exc()
        return 1


def process_batch(args: argparse.Namespace) -> int:
    """
    Process multiple files from CSV.
    
    Parameters
    ----------
    args : argparse.Namespace
        Parsed command-line arguments.
        
    Returns
    -------
    int
        Exit code (0 for success, 1 for failure).
    """
    csv_path = Path(args.input)
    output_dir = Path(args.output_dir)
    
    # Determine number of workers
    n_workers = args.workers
    if n_workers == -1:
        n_workers = os.cpu_count()
    
    try:
        # Print processing info
        if not args.quiet:
            print("=" * 70)
            print("MedTDA - Batch Processing")
            print("=" * 70)
            print(f"CSV file: {csv_path}")
            print(f"Output directory: {output_dir}")
            print(f"Vectorization methods: {', '.join(args.methods)}")
            print(f"Parallel workers: {n_workers}")
            if args.verbose:
                print(f"\nPreprocessing:")
                print(f"  Normalize: {args.normalize}")
                if args.normalize:
                    print(f"  Normalization method: {args.normalize_method}")
                if args.spacing:
                    print(f"  Target spacing: {args.spacing}")
                print(f"\nPersistent Homology:")
                print(f"  Filtration: {args.filtration}")
                print(f"  Construction: {args.construction}")
            print("-" * 70)
        
        # Load and validate CSV
        if args.verbose and not args.quiet:
            print("Loading CSV file...")
        
        try:
            cases_df = pd.read_csv(csv_path, index_col=False)
        except Exception as e:
            raise ValueError(f"Failed to read CSV file: {e}")
        
        # Validate required columns
        required_cols = ['id', 'image_path']
        missing_cols = [col for col in required_cols if col not in cases_df.columns]
        if missing_cols:
            raise ValueError(
                f"CSV missing required columns: {missing_cols}. "
                f"Required: id, image_path. Optional: mask_path"
            )
        
        # Add mask_path column if missing (fill with None)
        if 'mask_path' not in cases_df.columns:
            cases_df['mask_path'] = None
        
        # Replace empty strings with None
        cases_df['mask_path'] = cases_df['mask_path'].replace('', None)
        cases_df['mask_path'] = cases_df['mask_path'].replace(pd.NA, None)
        
        n_cases = len(cases_df)
        if not args.quiet:
            print(f"Found {n_cases} cases to process")
            print("-" * 70)
        
        # Save configuration for reproducibility
        config_output = output_dir / "batch_config.yaml"
        save_config(args, config_output)
        if args.verbose and not args.quiet:
            print(f"Configuration saved: {config_output}\n")
        
        # Process cases
        if n_workers == 1:
            # Sequential processing
            results = _process_batch_sequential(cases_df, args)
        else:
            # Parallel processing
            results = _process_batch_parallel(cases_df, args, n_workers)
        
        # Collect results into DataFrame
        if not args.quiet:
            print("\nCollecting results...")
        
        all_features = []
        success_count = 0
        error_count = 0
        
        for result in results:
            case_id = result['id']
            status = result['status']
            
            if status == 'success':
                success_count += 1
                all_features.append(result['features'])
            else:
                error_count += 1
                if args.verbose:
                    print(f"  [ERROR] {case_id}: {result.get('error', 'Unknown error')}")
        
        # Save combined features to CSV
        if all_features:
            features_df = pd.DataFrame(all_features)
            output_path = output_dir / "batch_features.csv"
            features_df.to_csv(output_path, index=False)
            if not args.quiet:
                print(f"[OK] Features saved: {output_path}")
        
        # Print summary
        if not args.quiet:
            print("-" * 70)
            print(f"Processing complete!")
            print(f"  Successful: {success_count}/{n_cases}")
            print(f"  Failed: {error_count}/{n_cases}")
            print("=" * 70)
        
        return 0 if error_count == 0 else 1
        
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\nProcessing interrupted by user", file=sys.stderr)
        return 130
    except Exception as e:
        print(f"Error: Batch processing failed - {e}", file=sys.stderr)
        if args.verbose:
            traceback.print_exc()
        return 1


def _process_single_case(
    case_id: str,
    image_path: str,
    mask_path: Optional[str],
    args: argparse.Namespace
) -> Dict[str, Any]:
    """
    Process a single case (worker function for batch processing).
    
    Parameters
    ----------
    case_id : str
        Case identifier.
    image_path : str
        Path to image file.
    mask_path : str or None
        Path to mask file (optional).
    args : argparse.Namespace
        Processing arguments.
        
    Returns
    -------
    dict
        Result dictionary with keys: id, status, features (if success), error (if failed).
    """
    output_dir = Path(args.output_dir)
    
    try:
        # Convert spacing and window to tuples if provided
        spacing = tuple(args.spacing) if args.spacing else None
        window = tuple(args.window) if args.window else None
        
        # Initialize FeatureExtractor
        extractor = FeatureExtractor(
            spacing=spacing,
            window=window,
            normalize=args.normalize,
            normalize_method=args.normalize_method,
            background_value=args.background_value,
            label=args.label,
            crop_to_roi=args.crop_roi,
            roi_padding=args.roi_padding,
            filtration_type=args.filtration,
            construction=args.construction,
            max_dimension=args.max_dimension,
            return_barcodes=args.save_barcodes,
            vectorization_method=args.methods
        )
        
        # Execute feature extraction
        result = extractor.execute(
            image=image_path,
            mask=mask_path if mask_path and pd.notna(mask_path) else None
        )
        
        # Handle result
        if args.save_barcodes:
            features, barcodes = result  # type: ignore[misc]
        else:
            features = result  # type: ignore[assignment]
            barcodes = None
        
        # Add case ID to features
        features_with_id = {'id': case_id, **features}  # type: ignore[arg-type,dict-item]
        
        # Save barcodes if requested
        if args.save_barcodes and barcodes is not None:
            barcodes_output = output_dir / f"{case_id}_barcodes.pkl"
            with open(barcodes_output, 'wb') as f:
                pickle.dump(barcodes, f)
        
        return {
            'id': case_id,
            'status': 'success',
            'features': features_with_id
        }
        
    except Exception as e:
        error_msg = str(e)
        if args.verbose:
            error_msg = traceback.format_exc()
        
        return {
            'id': case_id,
            'status': 'error',
            'error': error_msg,
            'features': {'id': case_id, 'status': 'error', 'error_message': str(e)}
        }


def _process_batch_sequential(
    cases_df: pd.DataFrame,
    args: argparse.Namespace
) -> List[Dict[str, Any]]:
    """
    Process cases sequentially with progress bar.
    
    Parameters
    ----------
    cases_df : pd.DataFrame
        DataFrame with cases to process.
    args : argparse.Namespace
        Processing arguments.
        
    Returns
    -------
    list
        List of result dictionaries.
    """
    results = []
    
    # Create progress bar
    disable_pbar = args.quiet
    pbar = tqdm(
        total=len(cases_df),
        desc="Processing cases",
        disable=disable_pbar,
        unit="case"
    )
    
    for _, row in cases_df.iterrows():
        case_id = row['id']
        image_path = row['image_path']
        mask_path = row.get('mask_path', None)
        
        result = _process_single_case(case_id, image_path, mask_path, args)
        results.append(result)
        
        pbar.update(1)
        if result['status'] == 'error' and not args.quiet:
            pbar.write(f"[ERROR] {case_id}: {result.get('error', 'Unknown error')[:80]}")
    
    pbar.close()
    return results


def _process_batch_parallel(
    cases_df: pd.DataFrame,
    args: argparse.Namespace,
    n_workers: int
) -> List[Dict[str, Any]]:
    """
    Process cases in parallel with progress bar.
    
    Parameters
    ----------
    cases_df : pd.DataFrame
        DataFrame with cases to process.
    args : argparse.Namespace
        Processing arguments.
    n_workers : int
        Number of parallel workers.
        
    Returns
    -------
    list
        List of result dictionaries.
    """
    results = []
    
    # Create progress bar
    disable_pbar = args.quiet
    pbar = tqdm(
        total=len(cases_df),
        desc=f"Processing cases ({n_workers} workers)",
        disable=disable_pbar,
        unit="case"
    )
    
    # Submit all tasks
    with ProcessPoolExecutor(max_workers=n_workers) as executor:
        # Submit tasks
        future_to_case = {}
        for _, row in cases_df.iterrows():
            case_id = row['id']
            image_path = row['image_path']
            mask_path = row.get('mask_path', None)
            
            future = executor.submit(
                _process_single_case,
                case_id,
                image_path,
                mask_path,
                args
            )
            future_to_case[future] = case_id
        
        # Collect results as they complete
        for future in as_completed(future_to_case):
            case_id = future_to_case[future]
            try:
                result = future.result()
                results.append(result)
                
                if result['status'] == 'error' and not args.quiet:
                    error_short = result.get('error', 'Unknown error')
                    if len(error_short) > 80:
                        error_short = error_short[:77] + "..."
                    pbar.write(f"[ERROR] {case_id}: {error_short}")
                    
            except Exception as e:
                # Worker process crashed
                error_msg = f"Worker process failed: {e}"
                if args.verbose:
                    error_msg = traceback.format_exc()
                
                results.append({
                    'id': case_id,
                    'status': 'error',
                    'error': error_msg,
                    'features': {'id': case_id, 'status': 'error', 'error_message': str(e)}
                })
                
                if not args.quiet:
                    pbar.write(f"[ERROR] {case_id}: Worker crashed - {str(e)[:60]}")
            
            pbar.update(1)
    
    pbar.close()
    return results


def main() -> int:
    """
    Main CLI entry point.
    
    Returns
    -------
    int
        Exit code (0 for success, non-zero for failure).
    """
    # Create parser
    parser = create_parser()
    
    # Parse arguments
    args = parser.parse_args()
    
    try:
        # Load and merge configuration if provided
        if args.config is not None:
            config = load_config(args.config)
            args = merge_config_with_args(config, args)
        
        # Validate arguments
        validate_args(args)
        
        # Detect mode and route to appropriate handler
        mode = detect_mode(args.input)
        
        if mode == 'single':
            return process_single_file(args)
        else:  # batch mode
            return process_batch(args)
            
    except ValueError as e:
        print(f"Error: {e}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\nInterrupted by user", file=sys.stderr)
        return 130
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr)
        if args.verbose:
            import traceback
            traceback.print_exc()
        return 1


if __name__ == '__main__':
    sys.exit(main())
