use clap::{Parser, Subcommand};
use pixstrip_core::discovery;
use pixstrip_core::executor::PipelineExecutor;
use pixstrip_core::operations::*;
use pixstrip_core::pipeline::ProcessingJob;
use pixstrip_core::preset::Preset;
use pixstrip_core::storage::{HistoryStore, PresetStore};
use pixstrip_core::types::*;
use std::io::Write;
use std::path::PathBuf;

fn watch_config_dir() -> PathBuf {
    dirs::config_dir()
        .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
        .unwrap_or_else(|| std::env::temp_dir())
        .join("pixstrip")
}

fn load_watches(watches_path: &std::path::Path) -> Vec<pixstrip_core::watcher::WatchFolder> {
    std::fs::read_to_string(watches_path)
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .unwrap_or_default()
}

#[derive(Parser)]
#[command(name = "pixstrip")]
#[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")]
#[command(version)]
#[command(after_long_help = "\
EXAMPLES:
  Resize all images in a folder to 1200px wide:
    pixstrip process photos/ --resize 1200

  Convert PNGs to WebP at high quality:
    pixstrip process *.png --format webp --quality high

  Fit images within 1920x1080 without upscaling:
    pixstrip process photos/ --resize fit:1920x1080

  Strip metadata for privacy and rename to lowercase:
    pixstrip process photos/ --metadata privacy --rename-case lower

  Add a tiled watermark with custom color and rotation:
    pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled --watermark-rotation 45 --watermark-color ff0000

  Process with a saved preset:
    pixstrip process photos/ --preset \"Blog Photos\"

  Watch a folder and auto-process new images:
    pixstrip watch add ~/incoming --preset \"Web Upload\"
    pixstrip watch start

For more details on any subcommand:
  pixstrip help <command>
  pixstrip process --help")]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Commands {
    /// Process images with a preset or custom operations
    #[command(after_long_help = "\
EXAMPLES:
  Resize all JPEGs in a folder to 1200px wide:
    pixstrip process photos/ --resize 1200 -o output/

  Fit images within a 1920x1080 box (no upscaling):
    pixstrip process photos/ --resize fit:1920x1080

  Convert all PNGs to WebP with high quality:
    pixstrip process *.png --format webp --quality high

  Per-format conversion (PNGs to WebP, TIFFs to JPEG):
    pixstrip process photos/ --format-map \"png=webp,tiff=jpeg\"

  Fine-tune compression per format:
    pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70

  Strip location and camera data, keep copyright:
    pixstrip process photos/ --strip-gps --strip-camera

  Adjust brightness/contrast and convert to grayscale:
    pixstrip process photos/ --brightness 10 --contrast 20 --grayscale

  Add a tiled diagonal watermark in red:
    pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled \\
      --watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3

  Add an image watermark (logo) scaled to 15%:
    pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15

  Rename to lowercase with hyphens, add prefix:
    pixstrip process photos/ --rename-prefix \"blog-\" --rename-case lower \\
      --rename-spaces hyphen

  Rename with regex (remove \"IMG_\" prefix):
    pixstrip process photos/ --rename-regex-find \"^IMG_\" --rename-regex-replace \"\"

  Sequential numbering (replace filename with counter):
    pixstrip process photos/ --rename-counter-position replace-name \\
      --rename-counter-start 1 --rename-counter-padding 4

  Use a saved preset:
    pixstrip process photos/ --preset \"Blog Photos\"

  Override preset options (preset + custom flags):
    pixstrip process photos/ --preset \"Blog Photos\" --resize 800

  Combine multiple operations:
    pixstrip process photos/ -r --resize fit:1920x1080 --format webp \\
      --quality high --strip-metadata --rename-case lower \\
      --watermark \"(c) 2026\" --overwrite skip

TEMPLATE VARIABLES (for --rename-template):
  {name}      Original filename (without extension)
  {ext}       File extension
  {counter}   Sequential number (padding from --rename-counter-padding)
  {counter:N} Sequential number with N-digit padding
  {width}     Image width in pixels
  {height}    Image height in pixels
  {date}      File modification date (YYYY-MM-DD)

  Example: --rename-template \"{name}_{width}x{height}.{ext}\"")]
    Process {
        /// Input files or directories
        #[arg(required = true)]
        input: Vec<String>,

        /// Preset name to use (see 'pixstrip preset list' for available presets)
        #[arg(long)]
        preset: Option<String>,

        /// Output directory [default: <input>/processed]
        #[arg(short, long)]
        output: Option<String>,

        // --- Resize ---

        /// Resize images. Formats: "1200" (width), "1200x900" (exact), "fit:1200x900" (fit in box)
        #[arg(long, help_heading = "Resize")]
        resize: Option<String>,

        /// Resize algorithm
        #[arg(long, default_value = "lanczos3", help_heading = "Resize",
              value_parser = ["lanczos3", "catmullrom", "bilinear", "nearest"])]
        algorithm: String,

        /// Allow upscaling when using fit-in-box resize mode
        #[arg(long, help_heading = "Resize")]
        allow_upscale: bool,

        // --- Convert ---

        /// Convert all images to one format
        #[arg(long, help_heading = "Convert",
              value_parser = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "bmp"])]
        format: Option<String>,

        /// Per-format conversion mapping (e.g., "png=webp,tiff=jpeg"). Cannot combine with --format
        #[arg(long, help_heading = "Convert")]
        format_map: Option<String>,

        // --- Compress ---

        /// Quality preset
        #[arg(long, help_heading = "Compress",
              value_parser = ["maximum", "max", "high", "medium", "med", "low", "web"])]
        quality: Option<String>,

        /// JPEG quality (1-100). Overrides --quality for JPEG output
        #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
        jpeg_quality: Option<u8>,

        /// PNG compression level (1-12, lower = larger but faster). Overrides --quality for PNG
        #[arg(long, value_parser = clap::value_parser!(u8).range(1..=12), help_heading = "Compress")]
        png_level: Option<u8>,

        /// WebP quality (1-100). Overrides --quality for WebP output
        #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
        webp_quality: Option<u8>,

        /// AVIF quality (1-100). Overrides --quality for AVIF output
        #[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
        avif_quality: Option<u8>,

        /// AVIF encoding speed (0=slowest/best, 10=fastest/worst)
        #[arg(long, value_parser = clap::value_parser!(u8).range(0..=10), help_heading = "Compress")]
        avif_speed: Option<u8>,

        /// Enable progressive JPEG encoding (better for web, slightly larger files)
        #[arg(long, help_heading = "Compress")]
        progressive_jpeg: bool,

        // --- Metadata ---

        /// Metadata handling mode. "privacy" strips GPS, camera, and software info
        #[arg(long, help_heading = "Metadata",
              value_parser = ["strip-all", "keep-all", "privacy"])]
        metadata: Option<String>,

        /// Strip GPS/location data
        #[arg(long, help_heading = "Metadata")]
        strip_gps: bool,

        /// Strip camera make/model/settings info
        #[arg(long, help_heading = "Metadata")]
        strip_camera: bool,

        /// Strip software/editor tags
        #[arg(long, help_heading = "Metadata")]
        strip_software: bool,

        /// Strip date/time timestamps
        #[arg(long, help_heading = "Metadata")]
        strip_timestamps: bool,

        /// Strip copyright and author info
        #[arg(long, help_heading = "Metadata")]
        strip_copyright: bool,

        /// Strip all metadata (shorthand for --metadata strip-all)
        #[arg(long, help_heading = "Metadata")]
        strip_metadata: bool,

        // --- Rotation / Flip ---

        /// Rotate images. "auto" reads EXIF orientation and corrects it
        #[arg(long, help_heading = "Transform",
              value_parser = ["90", "180", "270", "auto", "none"])]
        rotate: Option<String>,

        /// Flip/mirror images
        #[arg(long, help_heading = "Transform",
              value_parser = ["horizontal", "vertical", "h", "v", "none"])]
        flip: Option<String>,

        // --- Adjustments ---

        /// Brightness adjustment (-100 to 100, 0 = no change)
        #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
        brightness: Option<i32>,

        /// Contrast adjustment (-100 to 100, 0 = no change)
        #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
        contrast: Option<i32>,

        /// Saturation adjustment (-100 to 100, 0 = no change)
        #[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
        saturation: Option<i32>,

        /// Apply sharpening filter
        #[arg(long, help_heading = "Adjustments")]
        sharpen: bool,

        /// Convert to grayscale (black and white)
        #[arg(long, help_heading = "Adjustments")]
        grayscale: bool,

        /// Apply sepia tone (vintage look)
        #[arg(long, help_heading = "Adjustments")]
        sepia: bool,

        /// Crop to aspect ratio (e.g., "16:9", "4:3", "1:1", "3:2")
        #[arg(long, help_heading = "Adjustments")]
        crop_aspect_ratio: Option<String>,

        /// Auto-trim whitespace/solid-color borders
        #[arg(long, help_heading = "Adjustments")]
        trim_whitespace: bool,

        /// Add uniform canvas padding in pixels (applied after all other adjustments)
        #[arg(long, help_heading = "Adjustments")]
        canvas_padding: Option<u32>,

        // --- Watermark ---

        /// Add text watermark. Cannot combine with --watermark-image
        #[arg(long, help_heading = "Watermark")]
        watermark: Option<String>,

        /// Add image watermark from file. Cannot combine with --watermark
        #[arg(long, help_heading = "Watermark")]
        watermark_image: Option<String>,

        /// Watermark position on the image
        #[arg(long, default_value = "bottom-right", help_heading = "Watermark",
              value_parser = ["top-left", "top-center", "top-right",
                              "center", "bottom-left", "bottom-center", "bottom-right",
                              "tl", "tc", "tr", "c", "bl", "bc", "br"])]
        watermark_position: String,

        /// Watermark opacity (0.0 = invisible, 1.0 = fully opaque)
        #[arg(long, default_value = "0.5", value_parser = parse_opacity, help_heading = "Watermark")]
        watermark_opacity: f32,

        /// Font size for text watermarks in points
        #[arg(long, default_value = "24", help_heading = "Watermark")]
        watermark_font_size: f32,

        /// Font family name for text watermarks (searches system fonts)
        #[arg(long, help_heading = "Watermark")]
        watermark_font: Option<String>,

        /// Text color as hex RGB or RGBA (e.g., "ffffff", "ff0000", "00000080")
        #[arg(long, default_value = "ffffff", help_heading = "Watermark")]
        watermark_color: String,

        /// Rotate watermark in degrees (45, -45, 90, or any value)
        #[arg(long, help_heading = "Watermark")]
        watermark_rotation: Option<String>,

        /// Tile (repeat) the watermark across the entire image
        #[arg(long, help_heading = "Watermark")]
        watermark_tiled: bool,

        /// Watermark distance from image edges in pixels
        #[arg(long, default_value = "10", help_heading = "Watermark")]
        watermark_margin: u32,

        /// Scale for image watermarks (0.0-1.0, fraction of image size)
        #[arg(long, default_value = "0.25", value_parser = parse_opacity, help_heading = "Watermark")]
        watermark_scale: f32,

        // --- Rename ---

        /// Add prefix before filename (e.g., "blog-" turns "photo.jpg" into "blog-photo.jpg")
        #[arg(long, help_heading = "Rename")]
        rename_prefix: Option<String>,

        /// Add suffix after filename (e.g., "-web" turns "photo.jpg" into "photo-web.jpg")
        #[arg(long, help_heading = "Rename")]
        rename_suffix: Option<String>,

        /// Rename using a template. Overrides --rename-prefix/--rename-suffix. See TEMPLATE VARIABLES below
        #[arg(long, help_heading = "Rename")]
        rename_template: Option<String>,

        /// Convert filename case
        #[arg(long, help_heading = "Rename",
              value_parser = ["lower", "upper", "title", "none"])]
        rename_case: Option<String>,

        /// Replace spaces in filenames
        #[arg(long, help_heading = "Rename",
              value_parser = ["underscore", "hyphen", "dot", "camelcase", "remove"])]
        rename_spaces: Option<String>,

        /// Filter special characters in filenames
        #[arg(long, help_heading = "Rename",
              value_parser = ["filesystem-safe", "web-safe", "hyphens-underscores", "hyphens", "alphanumeric", "none"])]
        rename_special_chars: Option<String>,

        /// Regex pattern to find in filenames (paired with --rename-regex-replace)
        #[arg(long, help_heading = "Rename")]
        rename_regex_find: Option<String>,

        /// Replacement for regex matches (supports $1, $2 capture groups)
        #[arg(long, help_heading = "Rename")]
        rename_regex_replace: Option<String>,

        /// Where to insert the counter in the filename
        #[arg(long, default_value = "after-suffix", help_heading = "Rename",
              value_parser = ["before-prefix", "before-name", "after-name", "after-suffix", "replace-name"])]
        rename_counter_position: String,

        /// Counter starting number
        #[arg(long, default_value = "1", help_heading = "Rename")]
        rename_counter_start: u32,

        /// Counter zero-padding width (3 = "001", 4 = "0001")
        #[arg(long, default_value = "3", help_heading = "Rename")]
        rename_counter_padding: u32,

        // --- Output ---

        /// What to do when output file already exists
        #[arg(long, default_value = "auto-rename", help_heading = "Output",
              value_parser = ["auto-rename", "overwrite", "skip"])]
        overwrite: String,

        /// Preserve subdirectory structure from input in the output folder
        #[arg(long, help_heading = "Output")]
        preserve_dirs: bool,

        /// Set output DPI/resolution metadata (e.g., 72, 150, 300)
        #[arg(long, help_heading = "Output")]
        output_dpi: Option<u32>,

        /// Scan input directories recursively for images
        #[arg(short, long, help_heading = "Output")]
        recursive: bool,
    },

    /// Manage presets (list, create, delete, import, export)
    Preset {
        #[command(subcommand)]
        action: PresetAction,
    },

    /// Manage watch folders for automatic processing of new images
    Watch {
        #[command(subcommand)]
        action: WatchAction,
    },

    /// View processing history (recent batches, file counts, sizes, and times)
    History,

    /// Undo recent batch operations by moving output files to system trash
    Undo {
        /// Number of recent batches to undo
        #[arg(long, default_value = "1")]
        last: usize,
    },
}

#[derive(Subcommand)]
enum WatchAction {
    /// Add a folder to watch for new images, with a preset to apply automatically
    Add {
        /// Folder path to watch for new images
        path: String,
        /// Preset to apply to new images (see 'pixstrip preset list')
        #[arg(long)]
        preset: String,
        /// Also watch subdirectories
        #[arg(short, long)]
        recursive: bool,
    },
    /// List all configured watch folders and their linked presets
    List,
    /// Remove a folder from the watch list
    Remove {
        /// Folder path to stop watching
        path: String,
    },
    /// Start watching all active folders. Blocks until Ctrl+C. Processed files go to <folder>/processed/
    Start,
}

#[derive(Subcommand)]
enum PresetAction {
    /// List all available presets (built-in and user-created)
    List,
    /// Create a new empty user preset. Edit it by exporting, modifying the JSON, and re-importing
    Create {
        /// Name for the new preset
        name: String,
        /// Optional description
        #[arg(long, default_value = "")]
        description: String,
    },
    /// Delete a user preset. Built-in presets cannot be deleted
    Delete {
        /// Name of the preset to delete
        name: String,
    },
    /// Export a preset to a JSON file for backup or sharing
    Export {
        /// Name of the preset to export
        name: String,
        /// Output file path (e.g., "my-preset.json")
        #[arg(short, long)]
        output: String,
    },
    /// Import a preset from a JSON file
    Import {
        /// Path to the preset JSON file
        path: String,
    },
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Process {
            input,
            preset,
            output,
            resize,
            algorithm,
            allow_upscale,
            format,
            format_map,
            quality,
            jpeg_quality,
            png_level,
            webp_quality,
            avif_quality,
            avif_speed,
            progressive_jpeg,
            metadata,
            strip_gps,
            strip_camera,
            strip_software,
            strip_timestamps,
            strip_copyright,
            strip_metadata,
            rotate,
            flip,
            brightness,
            contrast,
            saturation,
            sharpen,
            grayscale,
            sepia,
            crop_aspect_ratio,
            trim_whitespace,
            canvas_padding,
            watermark,
            watermark_image,
            watermark_position,
            watermark_opacity,
            watermark_font_size,
            watermark_font,
            watermark_color,
            watermark_rotation,
            watermark_tiled,
            watermark_margin,
            watermark_scale,
            rename_prefix,
            rename_suffix,
            rename_template,
            rename_case,
            rename_spaces,
            rename_special_chars,
            rename_regex_find,
            rename_regex_replace,
            rename_counter_position,
            rename_counter_start,
            rename_counter_padding,
            overwrite,
            preserve_dirs,
            output_dpi,
            recursive,
        } => {
            cmd_process(CmdProcessArgs {
                input,
                preset,
                output,
                resize,
                algorithm,
                allow_upscale,
                format,
                format_map,
                quality,
                jpeg_quality,
                png_level,
                webp_quality,
                avif_quality,
                avif_speed,
                progressive_jpeg,
                metadata,
                strip_gps,
                strip_camera,
                strip_software,
                strip_timestamps,
                strip_copyright,
                strip_metadata,
                rotate,
                flip,
                brightness,
                contrast,
                saturation,
                sharpen,
                grayscale,
                sepia,
                crop_aspect_ratio,
                trim_whitespace,
                canvas_padding,
                watermark,
                watermark_image,
                watermark_position,
                watermark_opacity,
                watermark_font_size,
                watermark_font,
                watermark_color,
                watermark_rotation,
                watermark_tiled,
                watermark_margin,
                watermark_scale,
                rename_prefix,
                rename_suffix,
                rename_template,
                rename_case,
                rename_spaces,
                rename_special_chars,
                rename_regex_find,
                rename_regex_replace,
                rename_counter_position,
                rename_counter_start,
                rename_counter_padding,
                overwrite,
                preserve_dirs,
                output_dpi,
                recursive,
            });
        }
        Commands::Preset { action } => match action {
            PresetAction::List => cmd_preset_list(),
            PresetAction::Create { name, description } => cmd_preset_create(&name, &description),
            PresetAction::Delete { name } => cmd_preset_delete(&name),
            PresetAction::Export { name, output } => cmd_preset_export(&name, &output),
            PresetAction::Import { path } => cmd_preset_import(&path),
        },
        Commands::Watch { action } => match action {
            WatchAction::Add {
                path,
                preset,
                recursive,
            } => cmd_watch_add(&path, &preset, recursive),
            WatchAction::List => cmd_watch_list(),
            WatchAction::Remove { path } => cmd_watch_remove(&path),
            WatchAction::Start => cmd_watch_start(),
        },
        Commands::History => cmd_history(),
        Commands::Undo { last } => cmd_undo(last),
    }
}

struct CmdProcessArgs {
    input: Vec<String>,
    preset: Option<String>,
    output: Option<String>,
    // Resize
    resize: Option<String>,
    algorithm: String,
    allow_upscale: bool,
    // Convert
    format: Option<String>,
    format_map: Option<String>,
    // Compress
    quality: Option<String>,
    jpeg_quality: Option<u8>,
    png_level: Option<u8>,
    webp_quality: Option<u8>,
    avif_quality: Option<u8>,
    avif_speed: Option<u8>,
    progressive_jpeg: bool,
    // Metadata
    metadata: Option<String>,
    strip_gps: bool,
    strip_camera: bool,
    strip_software: bool,
    strip_timestamps: bool,
    strip_copyright: bool,
    strip_metadata: bool,
    // Rotation / Flip
    rotate: Option<String>,
    flip: Option<String>,
    // Adjustments
    brightness: Option<i32>,
    contrast: Option<i32>,
    saturation: Option<i32>,
    sharpen: bool,
    grayscale: bool,
    sepia: bool,
    crop_aspect_ratio: Option<String>,
    trim_whitespace: bool,
    canvas_padding: Option<u32>,
    // Watermark
    watermark: Option<String>,
    watermark_image: Option<String>,
    watermark_position: String,
    watermark_opacity: f32,
    watermark_font_size: f32,
    watermark_font: Option<String>,
    watermark_color: String,
    watermark_rotation: Option<String>,
    watermark_tiled: bool,
    watermark_margin: u32,
    watermark_scale: f32,
    // Rename
    rename_prefix: Option<String>,
    rename_suffix: Option<String>,
    rename_template: Option<String>,
    rename_case: Option<String>,
    rename_spaces: Option<String>,
    rename_special_chars: Option<String>,
    rename_regex_find: Option<String>,
    rename_regex_replace: Option<String>,
    rename_counter_position: String,
    rename_counter_start: u32,
    rename_counter_padding: u32,
    // Output
    overwrite: String,
    preserve_dirs: bool,
    output_dpi: Option<u32>,
    recursive: bool,
}

fn cmd_process(args: CmdProcessArgs) {
    // Collect input files
    let mut source_files = Vec::new();
    for path_str in &args.input {
        let path = PathBuf::from(path_str);
        if path.is_dir() {
            let files = discovery::discover_images(&path, args.recursive);
            source_files.extend(files);
        } else if path.is_file() {
            if path.extension()
                .and_then(|e| e.to_str())
                .and_then(pixstrip_core::types::ImageFormat::from_extension)
                .is_some()
            {
                source_files.push(path);
            } else {
                eprintln!("Warning: '{}' is not a recognized image format, skipping", path_str);
            }
        } else {
            eprintln!("Warning: '{}' not found, skipping", path_str);
        }
    }

    if source_files.is_empty() {
        eprintln!("No images found in the specified input(s)");
        std::process::exit(1);
    }

    println!("Found {} image(s)", source_files.len());

    let input_dir = source_files[0]
        .parent()
        .unwrap_or_else(|| std::path::Path::new("."))
        .to_path_buf();

    let output_dir = args.output
        .as_ref()
        .map(PathBuf::from)
        .unwrap_or_else(|| input_dir.join("processed"));

    let mut job = if let Some(ref name) = args.preset {
        let preset = find_preset(name).unwrap_or_else(|| {
            eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
            std::process::exit(1);
        });
        println!("Using preset: {}", preset.name);
        preset.to_job(&input_dir, &output_dir)
    } else {
        ProcessingJob::new(&input_dir, &output_dir)
    };

    // Override with CLI flags

    // --- Resize ---
    if let Some(ref resize_str) = args.resize {
        job.resize = Some(parse_resize(resize_str, args.allow_upscale));
    }

    job.resize_algorithm = match args.algorithm.to_lowercase().as_str() {
        "catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom,
        "bilinear" => ResizeAlgorithm::Bilinear,
        "nearest" => ResizeAlgorithm::Nearest,
        "lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3,
        other => {
            eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other);
            ResizeAlgorithm::Lanczos3
        }
    };

    // --- Convert ---
    if args.format.is_some() && args.format_map.is_some() {
        eprintln!("Error: cannot use both --format and --format-map at the same time");
        std::process::exit(1);
    }
    if let Some(ref map_str) = args.format_map {
        let mappings = parse_format_map(map_str);
        if !mappings.is_empty() {
            job.convert = Some(ConvertConfig::FormatMapping(mappings));
        }
    } else if let Some(ref fmt_str) = args.format {
        let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1));
        job.convert = Some(ConvertConfig::SingleFormat(fmt));
    }

    // --- Compress ---
    if args.jpeg_quality.is_some() || args.png_level.is_some() || args.webp_quality.is_some() || args.avif_quality.is_some() {
        // Per-format custom quality overrides the preset
        job.compress = Some(CompressConfig::Custom {
            jpeg_quality: args.jpeg_quality,
            png_level: args.png_level,
            webp_quality: args.webp_quality.map(|q| q as f32),
            avif_quality: args.avif_quality.map(|q| q as f32),
        });
    } else if let Some(ref q_str) = args.quality {
        let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1));
        job.compress = Some(CompressConfig::Preset(preset));
    }

    if let Some(speed) = args.avif_speed {
        job.avif_speed = speed;
    }
    if args.progressive_jpeg {
        job.progressive_jpeg = true;
    }

    // --- Metadata ---
    if args.strip_metadata {
        job.metadata = Some(MetadataConfig::StripAll);
    } else if args.strip_gps || args.strip_camera || args.strip_software || args.strip_timestamps || args.strip_copyright {
        job.metadata = Some(MetadataConfig::Custom {
            strip_gps: args.strip_gps,
            strip_camera: args.strip_camera,
            strip_software: args.strip_software,
            strip_timestamps: args.strip_timestamps,
            strip_copyright: args.strip_copyright,
        });
    } else if let Some(ref mode) = args.metadata {
        job.metadata = Some(parse_metadata_mode(mode));
    }

    // --- Rotation / Flip ---
    if let Some(ref rot) = args.rotate {
        job.rotation = Some(parse_rotation(rot));
    }
    if let Some(ref fl) = args.flip {
        job.flip = Some(parse_flip(fl));
    }

    // --- Adjustments ---
    let has_adjustments = args.brightness.is_some()
        || args.contrast.is_some()
        || args.saturation.is_some()
        || args.sharpen
        || args.grayscale
        || args.sepia
        || args.crop_aspect_ratio.is_some()
        || args.trim_whitespace
        || args.canvas_padding.is_some();
    if has_adjustments {
        job.adjustments = Some(AdjustmentsConfig {
            brightness: args.brightness.unwrap_or(0).clamp(-100, 100),
            contrast: args.contrast.unwrap_or(0).clamp(-100, 100),
            saturation: args.saturation.unwrap_or(0).clamp(-100, 100),
            sharpen: args.sharpen,
            grayscale: args.grayscale,
            sepia: args.sepia,
            crop_aspect_ratio: args.crop_aspect_ratio.as_deref().and_then(parse_aspect_ratio),
            trim_whitespace: args.trim_whitespace,
            canvas_padding: args.canvas_padding.unwrap_or(0),
        });
    }

    // --- Watermark ---
    if args.watermark.is_some() && args.watermark_image.is_some() {
        eprintln!("Error: cannot use both --watermark (text) and --watermark-image at the same time");
        std::process::exit(1);
    }
    let wm_position = parse_watermark_position(&args.watermark_position);
    let wm_rotation = args.watermark_rotation.as_deref().map(parse_watermark_rotation);
    if let Some(ref text) = args.watermark {
        let color = parse_hex_color(&args.watermark_color);
        job.watermark = Some(WatermarkConfig::Text {
            text: text.clone(),
            position: wm_position,
            font_size: args.watermark_font_size,
            opacity: args.watermark_opacity,
            color,
            font_family: args.watermark_font.clone(),
            rotation: wm_rotation,
            tiled: args.watermark_tiled,
            margin: args.watermark_margin,
        });
    } else if let Some(ref img_path) = args.watermark_image {
        let path = PathBuf::from(img_path);
        if !path.exists() {
            eprintln!("Watermark image not found: {}", img_path);
            std::process::exit(1);
        }
        job.watermark = Some(WatermarkConfig::Image {
            path,
            position: wm_position,
            opacity: args.watermark_opacity,
            scale: args.watermark_scale,
            rotation: wm_rotation,
            tiled: args.watermark_tiled,
            margin: args.watermark_margin,
        });
    }

    // --- Rename ---
    let has_rename = args.rename_prefix.is_some()
        || args.rename_suffix.is_some()
        || args.rename_template.is_some()
        || args.rename_case.is_some()
        || args.rename_spaces.is_some()
        || args.rename_special_chars.is_some()
        || args.rename_regex_find.is_some();
    if has_rename {
        if let Some(ref tmpl) = args.rename_template {
            if args.rename_prefix.is_some() || args.rename_suffix.is_some() {
                eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix");
            }
            if !tmpl.contains('{') {
                eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl);
            }
            if !tmpl.contains("{ext}") && !tmpl.contains('.') {
                eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions");
            }
        }
        let case_mode = args.rename_case.as_deref().map(parse_case_mode).unwrap_or(0);
        let replace_spaces = args.rename_spaces.as_deref().map(parse_space_mode).unwrap_or(0);
        let special_chars = args.rename_special_chars.as_deref().map(parse_special_chars_mode).unwrap_or(0);
        let counter_position = parse_counter_position(&args.rename_counter_position);
        let counter_enabled = args.rename_template.is_some() || counter_position != 3;

        job.rename = Some(RenameConfig {
            prefix: args.rename_prefix.unwrap_or_default(),
            suffix: args.rename_suffix.unwrap_or_default(),
            counter_start: args.rename_counter_start,
            counter_padding: args.rename_counter_padding,
            counter_enabled,
            counter_position,
            template: args.rename_template,
            case_mode,
            replace_spaces,
            special_chars,
            regex_find: args.rename_regex_find.unwrap_or_default(),
            regex_replace: args.rename_regex_replace.unwrap_or_default(),
        });
    }

    // --- Output ---
    job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() {
        "overwrite" | "always" => OverwriteAction::Overwrite,
        "skip" => OverwriteAction::Skip,
        "auto-rename" | "autorename" | "rename" => OverwriteAction::AutoRename,
        other => {
            eprintln!("Warning: unknown overwrite mode '{}', using auto-rename. Valid: auto-rename, overwrite, skip", other);
            OverwriteAction::AutoRename
        }
    };

    if args.preserve_dirs {
        job.preserve_directory_structure = true;
    }
    if let Some(dpi) = args.output_dpi {
        job.output_dpi = dpi;
    }

    for file in &source_files {
        job.add_source(file);
    }

    // Execute
    let executor = PipelineExecutor::new();
    let result = executor
        .execute(&job, |update| {
            eprint!(
                "\r[{}/{}] {}...",
                update.current, update.total, update.current_file
            );
            let _ = std::io::stderr().flush();
        })
        .unwrap_or_else(|e| {
            eprintln!("\nProcessing failed: {}", e);
            std::process::exit(1);
        });

    eprintln!();

    // Print results
    println!();
    println!("Processing complete:");
    println!("  Succeeded: {}", result.succeeded);
    if result.failed > 0 {
        println!("  Failed:    {}", result.failed);
    }
    if result.total_input_bytes > 0 && result.total_output_bytes > result.total_input_bytes {
        let increase = (result.total_output_bytes as f64 / result.total_input_bytes as f64 - 1.0) * 100.0;
        println!(
            "  Size:      {} -> {} (+{:.0}% increase)",
            format_bytes(result.total_input_bytes),
            format_bytes(result.total_output_bytes),
            increase,
        );
    } else {
        println!(
            "  Size:      {} -> {} ({:.0}% reduction)",
            format_bytes(result.total_input_bytes),
            format_bytes(result.total_output_bytes),
            if result.total_input_bytes > 0 {
                (1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0
            } else {
                0.0
            }
        );
    }
    println!("  Time:      {}", format_duration(result.elapsed_ms));
    println!("  Output:    {}", output_dir.display());

    if !result.errors.is_empty() {
        println!();
        println!("Errors:");
        for (file, err) in &result.errors {
            println!("  {} - {}", file, err);
        }
    }

    // Save to history - use actual output paths from the executor
    let history = HistoryStore::new();
    if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry {
        timestamp: unix_timestamp(),
        input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(),
        output_dir: output_dir.canonicalize().unwrap_or_else(|_| output_dir.to_path_buf()).to_string_lossy().into(),
        preset_name: args.preset,
        total: result.total,
        succeeded: result.succeeded,
        failed: result.failed,
        total_input_bytes: result.total_input_bytes,
        total_output_bytes: result.total_output_bytes,
        elapsed_ms: result.elapsed_ms,
        output_files: result.output_files,
    }, 50, 30) {
        eprintln!("Warning: failed to save history (undo may not work): {}", e);
    }
}

fn cmd_preset_list() {
    println!("Built-in presets:");
    for preset in Preset::all_builtins() {
        println!("  {} - {}", preset.name, preset.description);
    }

    let store = PresetStore::new();
    if let Ok(user_presets) = store.list()
        && !user_presets.is_empty()
    {
        println!();
        println!("User presets:");
        for preset in user_presets {
            println!("  {} - {}", preset.name, preset.description);
        }
    }
}

fn cmd_preset_create(name: &str, description: &str) {
    let store = PresetStore::new();
    // Check if name conflicts with a builtin
    let lower = name.to_lowercase();
    if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) {
        eprintln!("Cannot create preset '{}': conflicts with a built-in preset name", name);
        std::process::exit(1);
    }
    let preset = Preset {
        name: name.to_string(),
        description: description.to_string(),
        is_custom: true,
        ..Preset::default()
    };
    match store.save(&preset) {
        Ok(()) => println!("Created preset '{}'", name),
        Err(e) => {
            eprintln!("Failed to create preset: {}", e);
            std::process::exit(1);
        }
    }
}

fn cmd_preset_delete(name: &str) {
    let store = PresetStore::new();
    // Don't allow deleting builtins
    let lower = name.to_lowercase();
    if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) {
        eprintln!("Cannot delete built-in preset '{}'", name);
        std::process::exit(1);
    }
    match store.delete(name) {
        Ok(()) => println!("Deleted preset '{}'", name),
        Err(e) => {
            eprintln!("Failed to delete preset '{}': {}", name, e);
            std::process::exit(1);
        }
    }
}

fn cmd_preset_export(name: &str, output: &str) {
    let preset = find_preset(name).unwrap_or_else(|| {
        eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
        std::process::exit(1);
    });
    let store = PresetStore::new();
    match store.export_to_file(&preset, &PathBuf::from(output)) {
        Ok(()) => println!("Exported '{}' to '{}'", name, output),
        Err(e) => {
            eprintln!("Failed to export preset: {}", e);
            std::process::exit(1);
        }
    }
}

fn cmd_preset_import(path: &str) {
    let store = PresetStore::new();
    match store.import_from_file(&PathBuf::from(path)) {
        Ok(preset) => {
            let name = preset.name.clone();
            match store.save(&preset) {
                Ok(()) => println!("Imported preset '{}'", name),
                Err(e) => {
                    eprintln!("Failed to save imported preset: {}", e);
                    std::process::exit(1);
                }
            }
        }
        Err(e) => {
            eprintln!("Failed to import preset: {}", e);
            std::process::exit(1);
        }
    }
}

fn cmd_history() {
    let history = HistoryStore::new();
    match history.list() {
        Ok(entries) => {
            if entries.is_empty() {
                println!("No processing history yet.");
                return;
            }
            for (i, entry) in entries.iter().enumerate().rev() {
                println!(
                    "{}. [{}] {} -> {} ({}/{} succeeded, {})",
                    i + 1,
                    format_timestamp(&entry.timestamp),
                    entry.input_dir,
                    entry.output_dir,
                    entry.succeeded,
                    entry.total,
                    format_duration(entry.elapsed_ms),
                );
            }
        }
        Err(e) => {
            eprintln!("Failed to read history: {}", e);
            std::process::exit(1);
        }
    }
}

fn cmd_undo(count: usize) {
    if count == 0 {
        eprintln!("Must undo at least 1 batch");
        std::process::exit(1);
    }
    let history = HistoryStore::new();
    match history.list() {
        Ok(mut entries) => {
            if entries.is_empty() {
                println!("No processing history to undo.");
                return;
            }

            let undo_count = count.min(entries.len());
            if count > entries.len() {
                eprintln!("Warning: requested {} batches but only {} available", count, entries.len());
            }
            let to_undo = entries.split_off(entries.len() - undo_count);
            let mut total_trashed = 0;
            let mut failed_entries = Vec::new();

            for entry in to_undo {
                if entry.output_files.is_empty() {
                    println!(
                        "Batch from {} has no recorded output files - cannot undo",
                        format_timestamp(&entry.timestamp)
                    );
                    failed_entries.push(entry);
                    continue;
                }

                println!(
                    "Undoing batch: {} images from {}",
                    entry.total, entry.input_dir
                );

                let mut remaining_files = Vec::new();
                for file_path in &entry.output_files {
                    let path = PathBuf::from(file_path);
                    if path.exists() {
                        match trash::delete(&path) {
                            Ok(()) => {
                                total_trashed += 1;
                            }
                            Err(e) => {
                                eprintln!("  Failed to trash {}: {}", path.display(), e);
                                remaining_files.push(file_path.clone());
                            }
                        }
                    }
                }

                // Keep entry with remaining files if some could not be trashed
                if !remaining_files.is_empty() {
                    let mut kept = entry;
                    kept.output_files = remaining_files;
                    failed_entries.push(kept);
                }
            }

            // Re-add entries where trash failed so they can be retried
            entries.extend(failed_entries);

            // Write updated history
            if let Err(e) = history.write_all(&entries) {
                eprintln!("Warning: failed to update history after undo: {}", e);
            }

            println!("{} files moved to trash", total_trashed);
        }
        Err(e) => {
            eprintln!("Failed to read history: {}", e);
            std::process::exit(1);
        }
    }
}

fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
    // Verify the preset exists
    if find_preset(preset_name).is_none() {
        eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", preset_name);
        std::process::exit(1);
    }
    let watch_path = PathBuf::from(path);
    let watch_path = watch_path.canonicalize().unwrap_or(watch_path);
    if !watch_path.exists() {
        eprintln!("Watch folder does not exist: {}", path);
        std::process::exit(1);
    }
    if !watch_path.is_dir() {
        eprintln!("Watch path is not a directory: {}", path);
        std::process::exit(1);
    }

    // Save watch folder config
    let watch = pixstrip_core::watcher::WatchFolder {
        path: watch_path,
        preset_name: preset_name.to_string(),
        recursive,
        active: true,
    };

    // Store in config
    let config_dir = watch_config_dir();
    let watches_path = config_dir.join("watches.json");
    let mut watches = load_watches(&watches_path);

    // Don't add duplicate paths
    if watches.iter().any(|w| w.path == watch.path) {
        println!("Watch folder already configured: {}", path);
        return;
    }

    watches.push(watch);
    if let Err(e) = std::fs::create_dir_all(&config_dir) {
        eprintln!("Failed to create config directory: {}", e);
        std::process::exit(1);
    }
    match serde_json::to_string_pretty(&watches) {
        Ok(json) => {
            if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) {
                eprintln!("Failed to write watch config: {}", e);
                std::process::exit(1);
            }
        }
        Err(e) => {
            eprintln!("Failed to serialize watch config: {}", e);
            std::process::exit(1);
        }
    }

    println!("Added watch: {} -> preset '{}'", path, preset_name);
}

fn cmd_watch_list() {
    let config_dir = watch_config_dir();
    let watches_path = config_dir.join("watches.json");

    let watches = load_watches(&watches_path);

    if watches.is_empty() {
        println!("No watch folders configured.");
        println!("Use 'pixstrip watch add <path> --preset <name>' to add one.");
        return;
    }

    println!("Configured watch folders:");
    for watch in &watches {
        let recursive_str = if watch.recursive { " (recursive)" } else { "" };
        let status = if watch.active { "active" } else { "inactive" };
        println!(
            "  {} -> '{}' [{}]{}",
            watch.path.display(),
            watch.preset_name,
            status,
            recursive_str
        );
    }
}

fn cmd_watch_remove(path: &str) {
    let config_dir = watch_config_dir();
    let watches_path = config_dir.join("watches.json");

    let mut watches = load_watches(&watches_path);

    let original_len = watches.len();
    let target = PathBuf::from(path);
    let target_canonical = target.canonicalize().unwrap_or(target.clone());
    watches.retain(|w| w.path != target && w.path != target_canonical);

    if watches.len() == original_len {
        println!("Watch folder not found: {}", path);
        return;
    }

    match serde_json::to_string_pretty(&watches) {
        Ok(json) => {
            if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) {
                eprintln!("Failed to write watch config: {}", e);
                std::process::exit(1);
            }
        }
        Err(e) => {
            eprintln!("Failed to serialize watch config: {}", e);
            std::process::exit(1);
        }
    }
    println!("Removed watch folder: {}", path);
}

fn cmd_watch_start() {
    let config_dir = watch_config_dir();
    let watches_path = config_dir.join("watches.json");

    let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
        match std::fs::read_to_string(&watches_path) {
            Ok(content) => match serde_json::from_str(&content) {
                Ok(w) => w,
                Err(e) => {
                    eprintln!("Warning: failed to parse watches.json: {}. Using empty config.", e);
                    Vec::new()
                }
            },
            Err(e) => {
                eprintln!("Warning: failed to read watches.json: {}", e);
                Vec::new()
            }
        }
    } else {
        Vec::new()
    };

    let active: Vec<_> = watches.iter().filter(|w| w.active).collect();
    if active.is_empty() {
        println!("No active watch folders. Use 'pixstrip watch add' first.");
        return;
    }

    println!("Starting watch on {} folder(s)...", active.len());
    for w in &active {
        println!("  {} -> '{}'", w.path.display(), w.preset_name);
    }
    println!("Press Ctrl+C to stop.");

    let (tx, rx) = std::sync::mpsc::channel();
    let mut watchers = Vec::new();
    // Collect output directories so we can skip files inside them (prevent infinite loop)
    let mut output_dirs: Vec<PathBuf> = Vec::new();

    for watch in &active {
        let watcher = pixstrip_core::watcher::FolderWatcher::new();
        if let Err(e) = watcher.start(watch, tx.clone()) {
            eprintln!("Failed to start watching {}: {}", watch.path.display(), e);
            continue;
        }
        output_dirs.push(watch.path.join("processed"));
        watchers.push((watcher, watch.preset_name.clone()));
    }

    // Drop the original sender so the channel closes when all watcher threads exit
    drop(tx);

    if watchers.is_empty() {
        eprintln!("Failed to start any watchers. Check that watch folders exist and are accessible.");
        return;
    }

    // Process incoming files
    for event in &rx {
        match event {
            pixstrip_core::watcher::WatchEvent::NewImage(path) => {
                // Skip files inside output directories to prevent infinite processing loop.
                // Check if the file is a direct child of any "processed" subdirectory
                // under a watched folder.
                let in_output_dir = output_dirs.iter().any(|d| path.starts_with(d))
                    || active.iter().any(|w| {
                        // Skip if the file's immediate parent is "processed" under the watch root
                        path.parent()
                            .and_then(|p| p.file_name())
                            .is_some_and(|name| name == "processed")
                            && path.starts_with(&w.path)
                    });
                if in_output_dir {
                    continue;
                }

                println!("New image: {}", path.display());

                // Wait for file to be fully written (check size stability)
                {
                    let mut last_size = 0u64;
                    for _ in 0..10 {
                        std::thread::sleep(std::time::Duration::from_millis(200));
                        let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
                        if size > 0 && size == last_size {
                            break;
                        }
                        last_size = size;
                    }
                }

                // Find which watcher owns this path and use its preset
                let matched = active.iter()
                    .find(|w| path.starts_with(&w.path))
                    .map(|w| w.preset_name.clone());
                if matched.is_none() {
                    eprintln!("  Warning: no matching watch folder for {}, skipping", path.display());
                    continue;
                }
                if let Some(preset_name) = matched.as_deref() {
                    let Some(preset) = find_preset(preset_name) else {
                        eprintln!("  Preset '{}' not found, skipping", preset_name);
                        continue;
                    };
                    let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
                    let output_dir = input_dir.join("processed");
                    let mut job = preset.to_job(&input_dir, &output_dir);
                    job.add_source(&path);

                    let executor = PipelineExecutor::new();
                    match executor.execute(&job, |_| {}) {
                        Ok(r) => {
                            println!("  Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes));
                            let history = HistoryStore::new();
                            let _ = history.add(pixstrip_core::storage::HistoryEntry {
                                timestamp: unix_timestamp(),
                                input_dir: input_dir.to_string_lossy().into(),
                                output_dir: output_dir.to_string_lossy().into(),
                                preset_name: Some(preset_name.to_string()),
                                total: r.total,
                                succeeded: r.succeeded,
                                failed: r.failed,
                                total_input_bytes: r.total_input_bytes,
                                total_output_bytes: r.total_output_bytes,
                                elapsed_ms: r.elapsed_ms,
                                output_files: r.output_files,
                            }, 50, 30);
                        }
                        Err(e) => eprintln!("  Failed: {}", e),
                    }
                }
            }
            pixstrip_core::watcher::WatchEvent::Error(err) => {
                eprintln!("Watch error: {}", err);
            }
        }
    }

    for (w, _) in &watchers {
        w.stop();
    }
}

// --- Helpers ---

fn find_preset(name: &str) -> Option<Preset> {
    // Check builtins first (case-insensitive)
    let lower = name.to_lowercase();
    for preset in Preset::all_builtins() {
        if preset.name.to_lowercase() == lower {
            return Some(preset);
        }
    }

    // Check user presets - try exact match first, then case-insensitive
    let store = PresetStore::new();
    if let Ok(preset) = store.load(name) {
        return Some(preset);
    }
    if let Ok(presets) = store.list() {
        for preset in presets {
            if preset.name.to_lowercase() == lower {
                return Some(preset);
            }
        }
    }

    None
}

fn parse_resize(s: &str, allow_upscale: bool) -> ResizeConfig {
    // Support "fit:WxH" for fit-in-box mode
    if let Some(dims) = s.strip_prefix("fit:") {
        let (w, h) = dims.split_once('x').unwrap_or_else(|| {
            eprintln!("Invalid fit-in-box syntax: '{}'. Use 'fit:1200x900'", s);
            std::process::exit(1);
        });
        let width: u32 = w.parse().unwrap_or_else(|_| {
            eprintln!("Invalid fit width: '{}'", w);
            std::process::exit(1);
        });
        let height: u32 = h.parse().unwrap_or_else(|_| {
            eprintln!("Invalid fit height: '{}'", h);
            std::process::exit(1);
        });
        if width == 0 || height == 0 {
            eprintln!("Fit dimensions must be greater than zero");
            std::process::exit(1);
        }
        return ResizeConfig::FitInBox {
            max: Dimensions { width, height },
            allow_upscale,
        };
    }

    if let Some((w, h)) = s.split_once('x') {
        let width: u32 = w.parse().unwrap_or_else(|_| {
            eprintln!("Invalid resize width: '{}'", w);
            std::process::exit(1);
        });
        let height: u32 = h.parse().unwrap_or_else(|_| {
            eprintln!("Invalid resize height: '{}'", h);
            std::process::exit(1);
        });
        if width == 0 || height == 0 {
            eprintln!("Resize dimensions must be greater than zero");
            std::process::exit(1);
        }
        ResizeConfig::Exact(Dimensions { width, height })
    } else {
        let width: u32 = s.parse().unwrap_or_else(|_| {
            eprintln!("Invalid resize value: '{}'. Use a width like '1200', dimensions like '1200x900', or 'fit:1200x900'", s);
            std::process::exit(1);
        });
        if width == 0 {
            eprintln!("Resize width must be greater than zero");
            std::process::exit(1);
        }
        ResizeConfig::ByWidth(width)
    }
}

fn parse_format(s: &str) -> Option<ImageFormat> {
    match s.to_lowercase().as_str() {
        "jpeg" | "jpg" => Some(ImageFormat::Jpeg),
        "png" => Some(ImageFormat::Png),
        "webp" => Some(ImageFormat::WebP),
        "avif" => Some(ImageFormat::Avif),
        "gif" => Some(ImageFormat::Gif),
        "tiff" | "tif" => Some(ImageFormat::Tiff),
        "bmp" => Some(ImageFormat::Bmp),
        _ => {
            eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff, bmp", s);
            None
        }
    }
}

fn parse_quality(s: &str) -> Option<QualityPreset> {
    match s.to_lowercase().as_str() {
        "maximum" | "max" => Some(QualityPreset::Maximum),
        "high" => Some(QualityPreset::High),
        "medium" | "med" => Some(QualityPreset::Medium),
        "low" => Some(QualityPreset::Low),
        "web" => Some(QualityPreset::WebOptimized),
        _ => {
            eprintln!("Unknown quality: '{}'. Supported: maximum, high, medium, low, web", s);
            None
        }
    }
}

fn parse_rotation(s: &str) -> Rotation {
    match s.to_lowercase().as_str() {
        "90" | "cw90" => Rotation::Cw90,
        "180" => Rotation::Cw180,
        "270" | "cw270" => Rotation::Cw270,
        "auto" => Rotation::AutoOrient,
        "none" | "0" => Rotation::None,
        _ => {
            eprintln!("Unknown rotation: '{}'. Supported: 90, 180, 270, auto, none", s);
            std::process::exit(1);
        }
    }
}

fn parse_flip(s: &str) -> Flip {
    match s.to_lowercase().as_str() {
        "horizontal" | "h" => Flip::Horizontal,
        "vertical" | "v" => Flip::Vertical,
        "none" => Flip::None,
        _ => {
            eprintln!("Unknown flip: '{}'. Supported: horizontal, vertical, none", s);
            std::process::exit(1);
        }
    }
}

fn parse_watermark_position(s: &str) -> WatermarkPosition {
    match s.to_lowercase().replace(' ', "-").as_str() {
        "top-left" | "tl" => WatermarkPosition::TopLeft,
        "top-center" | "tc" | "top" => WatermarkPosition::TopCenter,
        "top-right" | "tr" => WatermarkPosition::TopRight,
        "middle-left" | "ml" | "left" => WatermarkPosition::MiddleLeft,
        "center" | "c" | "middle" => WatermarkPosition::Center,
        "middle-right" | "mr" | "right" => WatermarkPosition::MiddleRight,
        "bottom-left" | "bl" => WatermarkPosition::BottomLeft,
        "bottom-center" | "bc" | "bottom" => WatermarkPosition::BottomCenter,
        "bottom-right" | "br" => WatermarkPosition::BottomRight,
        _ => {
            eprintln!("Unknown position: '{}'. Supported: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right", s);
            std::process::exit(1);
        }
    }
}

fn parse_opacity(s: &str) -> std::result::Result<f32, String> {
    let v: f32 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
    if !(0.0..=1.0).contains(&v) {
        return Err("opacity must be between 0.0 and 1.0".into());
    }
    Ok(v)
}

fn parse_format_map(s: &str) -> Vec<(ImageFormat, ImageFormat)> {
    let mut mappings = Vec::new();
    for pair in s.split(',') {
        let pair = pair.trim();
        if pair.is_empty() {
            continue;
        }
        let (from, to) = pair.split_once('=').unwrap_or_else(|| {
            eprintln!("Invalid format mapping: '{}'. Use 'from=to' (e.g., 'png=webp')", pair);
            std::process::exit(1);
        });
        let from_fmt = parse_format(from.trim()).unwrap_or_else(|| std::process::exit(1));
        let to_fmt = parse_format(to.trim()).unwrap_or_else(|| std::process::exit(1));
        if from_fmt == to_fmt {
            eprintln!("Warning: format mapping '{}={}' maps to itself, skipping", from.trim(), to.trim());
            continue;
        }
        mappings.push((from_fmt, to_fmt));
    }
    mappings
}

fn parse_metadata_mode(s: &str) -> MetadataConfig {
    match s.to_lowercase().replace(' ', "-").as_str() {
        "strip-all" | "strip" | "none" => MetadataConfig::StripAll,
        "keep-all" | "keep" | "all" => MetadataConfig::KeepAll,
        "privacy" => MetadataConfig::Privacy,
        other => {
            eprintln!("Unknown metadata mode: '{}'. Supported: strip-all, keep-all, privacy", other);
            eprintln!("For selective stripping, use --strip-gps, --strip-camera, etc.");
            std::process::exit(1);
        }
    }
}

fn parse_hex_color(s: &str) -> [u8; 4] {
    let hex = s.strip_prefix('#').unwrap_or(s);
    if hex.len() == 6 || hex.len() == 8 {
        if let Ok(val) = u32::from_str_radix(&hex[..6], 16) {
            let r = ((val >> 16) & 0xFF) as u8;
            let g = ((val >> 8) & 0xFF) as u8;
            let b = (val & 0xFF) as u8;
            let a = if hex.len() == 8 {
                u8::from_str_radix(&hex[6..8], 16).unwrap_or(255)
            } else {
                255
            };
            return [r, g, b, a];
        }
    }
    eprintln!("Warning: invalid hex color '{}', using white. Format: rrggbb or rrggbbaa", s);
    [255, 255, 255, 255]
}

fn parse_watermark_rotation(s: &str) -> WatermarkRotation {
    match s {
        "45" => WatermarkRotation::Degrees45,
        "-45" => WatermarkRotation::DegreesNeg45,
        "90" => WatermarkRotation::Degrees90,
        other => {
            let deg: f32 = other.parse().unwrap_or_else(|_| {
                eprintln!("Invalid watermark rotation: '{}'. Use 45, -45, 90, or a custom degree value", other);
                std::process::exit(1);
            });
            WatermarkRotation::Custom(deg)
        }
    }
}

fn parse_aspect_ratio(s: &str) -> Option<(f64, f64)> {
    if let Some((w, h)) = s.split_once(':') {
        let w: f64 = w.parse().unwrap_or_else(|_| {
            eprintln!("Invalid aspect ratio width: '{}'", w);
            std::process::exit(1);
        });
        let h: f64 = h.parse().unwrap_or_else(|_| {
            eprintln!("Invalid aspect ratio height: '{}'", h);
            std::process::exit(1);
        });
        if w <= 0.0 || h <= 0.0 {
            eprintln!("Aspect ratio values must be positive");
            std::process::exit(1);
        }
        Some((w, h))
    } else {
        eprintln!("Invalid aspect ratio: '{}'. Use format like '16:9' or '4:3'", s);
        std::process::exit(1);
    }
}

fn parse_case_mode(s: &str) -> u32 {
    match s.to_lowercase().as_str() {
        "lower" | "lowercase" => 1,
        "upper" | "uppercase" => 2,
        "title" | "titlecase" => 3,
        "none" => 0,
        other => {
            eprintln!("Unknown case mode: '{}'. Supported: lower, upper, title, none", other);
            std::process::exit(1);
        }
    }
}

fn parse_space_mode(s: &str) -> u32 {
    match s.to_lowercase().as_str() {
        "underscore" | "underscores" => 1,
        "hyphen" | "hyphens" | "dash" => 2,
        "dot" | "dots" | "period" => 3,
        "camelcase" | "camel" => 4,
        "remove" | "none" => 5,
        other => {
            eprintln!("Unknown space mode: '{}'. Supported: underscore, hyphen, dot, camelcase, remove", other);
            std::process::exit(1);
        }
    }
}

fn parse_special_chars_mode(s: &str) -> u32 {
    match s.to_lowercase().replace(' ', "-").as_str() {
        "filesystem-safe" | "filesystem" | "safe" => 1,
        "web-safe" | "web" => 2,
        "hyphens-underscores" | "hyphens-and-underscores" => 3,
        "hyphens" | "hyphens-only" => 4,
        "alphanumeric" | "alnum" => 5,
        "keep" | "none" => 0,
        other => {
            eprintln!("Unknown special chars mode: '{}'. Supported: filesystem-safe, web-safe, hyphens-underscores, hyphens, alphanumeric, none", other);
            std::process::exit(1);
        }
    }
}

fn parse_counter_position(s: &str) -> u32 {
    match s.to_lowercase().replace(' ', "-").as_str() {
        "before-prefix" => 0,
        "before-name" => 1,
        "after-name" => 2,
        "after-suffix" => 3,
        "replace-name" | "replace" => 4,
        other => {
            eprintln!("Unknown counter position: '{}'. Supported: before-prefix, before-name, after-name, after-suffix, replace-name", other);
            std::process::exit(1);
        }
    }
}

fn format_bytes(bytes: u64) -> String {
    if bytes < 1024 {
        format!("{} B", bytes)
    } else if bytes < 1024 * 1024 {
        format!("{:.1} KB", bytes as f64 / 1024.0)
    } else if bytes < 1024 * 1024 * 1024 {
        format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
    } else {
        format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
    }
}

fn format_duration(ms: u64) -> String {
    if ms < 1000 {
        format!("{}ms", ms)
    } else if ms < 60_000 {
        format!("{:.1}s", ms as f64 / 1000.0)
    } else {
        let mins = ms / 60_000;
        let secs = (ms % 60_000) / 1000;
        format!("{}m {}s", mins, secs)
    }
}

fn unix_timestamp() -> String {
    // Store as Unix seconds string (must match GTK format for pruning compatibility)
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
        .to_string()
}

/// Format a Unix-seconds timestamp string as human-readable "YYYY-MM-DD HH:MM:SS"
fn format_timestamp(ts: &str) -> String {
    let Ok(secs) = ts.parse::<u64>() else {
        return ts.to_string(); // already human-readable or unparseable - return as-is
    };

    let days = secs / 86400;
    let time_secs = secs % 86400;
    let hours = time_secs / 3600;
    let minutes = (time_secs % 3600) / 60;
    let seconds = time_secs % 60;

    let mut d = days;
    let mut year = 1970u64;
    loop {
        let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 366 } else { 365 };
        if d < days_in_year { break; }
        d -= days_in_year;
        year += 1;
    }
    let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
    let month_days: [u64; 12] = if leap {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };
    let mut month = 1u64;
    for md in &month_days {
        if d < *md { break; }
        d -= md;
        month += 1;
    }

    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", year, month, d + 1, hours, minutes, seconds)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_format_valid() {
        assert_eq!(parse_format("jpeg"), Some(ImageFormat::Jpeg));
        assert_eq!(parse_format("jpg"), Some(ImageFormat::Jpeg));
        assert_eq!(parse_format("PNG"), Some(ImageFormat::Png));
        assert_eq!(parse_format("webp"), Some(ImageFormat::WebP));
        assert_eq!(parse_format("avif"), Some(ImageFormat::Avif));
        assert_eq!(parse_format("bmp"), Some(ImageFormat::Bmp));
    }

    #[test]
    fn parse_format_invalid() {
        assert_eq!(parse_format("xyz"), None);
    }

    #[test]
    fn parse_quality_valid() {
        assert_eq!(parse_quality("maximum"), Some(QualityPreset::Maximum));
        assert_eq!(parse_quality("max"), Some(QualityPreset::Maximum));
        assert_eq!(parse_quality("high"), Some(QualityPreset::High));
        assert_eq!(parse_quality("medium"), Some(QualityPreset::Medium));
        assert_eq!(parse_quality("med"), Some(QualityPreset::Medium));
        assert_eq!(parse_quality("low"), Some(QualityPreset::Low));
        assert_eq!(parse_quality("web"), Some(QualityPreset::WebOptimized));
    }

    #[test]
    fn parse_quality_invalid() {
        assert_eq!(parse_quality("ultra"), None);
        assert_eq!(parse_quality(""), None);
    }

    #[test]
    fn parse_opacity_valid() {
        assert!(parse_opacity("0.5").is_ok());
        assert!(parse_opacity("0.0").is_ok());
        assert!(parse_opacity("1.0").is_ok());
    }

    #[test]
    fn parse_opacity_invalid() {
        assert!(parse_opacity("1.5").is_err());
        assert!(parse_opacity("-0.1").is_err());
        assert!(parse_opacity("abc").is_err());
    }

    #[test]
    fn format_bytes_ranges() {
        assert_eq!(format_bytes(500), "500 B");
        assert_eq!(format_bytes(1024), "1.0 KB");
        assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
        assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
    }

    #[test]
    fn format_duration_ranges() {
        assert_eq!(format_duration(500), "500ms");
        assert_eq!(format_duration(1500), "1.5s");
        assert_eq!(format_duration(90_000), "1m 30s");
    }

    #[test]
    fn find_preset_builtins() {
        assert!(find_preset("Blog Photos").is_some());
        assert!(find_preset("blog photos").is_some());
        assert!(find_preset("nonexistent preset xyz").is_none());
    }

    #[test]
    fn parse_resize_width_only() {
        let config = parse_resize("1200", false);
        assert!(matches!(config, ResizeConfig::ByWidth(1200)));
    }

    #[test]
    fn parse_resize_exact() {
        let config = parse_resize("1200x900", false);
        assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 })));
    }

    #[test]
    fn parse_resize_fit_in_box() {
        let config = parse_resize("fit:1200x900", false);
        assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 1200, height: 900 }, allow_upscale: false }));
        let config = parse_resize("fit:800x600", true);
        assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 800, height: 600 }, allow_upscale: true }));
    }

    #[test]
    fn parse_hex_color_valid() {
        assert_eq!(parse_hex_color("ff0000"), [255, 0, 0, 255]);
        assert_eq!(parse_hex_color("#00ff00"), [0, 255, 0, 255]);
        assert_eq!(parse_hex_color("0000ff80"), [0, 0, 255, 128]);
    }

    #[test]
    fn parse_case_mode_valid() {
        assert_eq!(parse_case_mode("lower"), 1);
        assert_eq!(parse_case_mode("upper"), 2);
        assert_eq!(parse_case_mode("title"), 3);
        assert_eq!(parse_case_mode("none"), 0);
    }

    #[test]
    fn parse_space_mode_valid() {
        assert_eq!(parse_space_mode("underscore"), 1);
        assert_eq!(parse_space_mode("hyphen"), 2);
        assert_eq!(parse_space_mode("dot"), 3);
        assert_eq!(parse_space_mode("camelcase"), 4);
        assert_eq!(parse_space_mode("remove"), 5);
    }

    #[test]
    fn parse_metadata_mode_valid() {
        assert!(matches!(parse_metadata_mode("strip-all"), MetadataConfig::StripAll));
        assert!(matches!(parse_metadata_mode("keep-all"), MetadataConfig::KeepAll));
        assert!(matches!(parse_metadata_mode("privacy"), MetadataConfig::Privacy));
    }

    #[test]
    fn parse_aspect_ratio_valid() {
        assert_eq!(parse_aspect_ratio("16:9"), Some((16.0, 9.0)));
        assert_eq!(parse_aspect_ratio("1:1"), Some((1.0, 1.0)));
    }
}
