Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
inc/managers/UploadManager.php
@@ -2,337 +2,294 @@
namespace JVBase\managers;
use JVBase\JVB;
use JVBase\meta\MetaManager;
use Exception;
use Imagick;
use WP_Error;
if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
   exit;
}
/**
 * Handles file uploads for edmonton.ink dashboard
 * Includes image processing, validation, optimization, and SEO-friendly naming
 * Flexible file upload manager for images, videos, and documents
 * Supports configurable directory structures and processing options
 */
class UploadManager
{
    /**
     * @var array Default configuration
     */
   protected array $config = [
      'allowed_types' => [
         'image/jpeg',
         'image/png',
         'image/gif',
         'image/webp',
      ],
      'max_size' => 5242880, // 5MB
      'convert_to_webp' => true,
      'webp_quality' => 80,
      'optimize_images' => true,
      'create_thumbnails' => true,
      'original_retention' => 2592000, // 30 days in seconds
      'use_imagick' => null // Will be set in constructor
   protected array $allowedTypes = [
      // Images
      'image/jpeg'    => 'image',
      'image/png'     => 'image',
      'image/gif'     => 'image',
      'image/webp'    => 'image',
      // Videos
      'video/mp4'     => 'video',
      'video/webm'    => 'video',
      'video/ogg'     => 'video',
      'video/ogv'     => 'video',
      'video/quicktime' => 'video',
      'video/x-msvideo' => 'video',
      // Documents
      'application/pdf' => 'document',
      'application/msword' => 'document',
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'document',
      'application/vnd.ms-excel' => 'document',
      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'document',
      'text/plain'    => 'document',
      'text/csv'      => 'document',
      'application/rtf' => 'document'
   ];
    protected string $post_type;
    protected int $user_id;
   protected int $term_id;
    protected string $upload_dir;
    protected string $upload_url;
    protected object $cache;
    protected object $notifications;
    protected int $max_retries = 3;
    protected int $retry_delay = 300; // 5 minutes
    public function __construct($post_type, $user_id, $config = [])
    {
        $this->post_type = $post_type;
        $this->user_id = $user_id;
        $this->config = wp_parse_args($config, $this->config);
        // Check for Imagick availability
        $this->config['use_imagick'] = extension_loaded('imagick');
   protected array $supportedFormats = [
      'webp' => 'image/webp',
      'jpeg' => 'image/jpeg',
      'jpg'  => 'image/jpeg',
      'png'  => 'image/png',
      'gif'  => 'image/gif'
   ];
        $this->setupUploadDirs();
   protected array $defaultConfig = [
      'allowed_types' => null, // null = all types allowed
      'max_size' => [
         'image' => 5242880,    // 5MB
         'video' => 104857600,  // 100MB
         'document' => 10485760 // 10MB
      ],
      // Image settings
      'convert' => 'webp',
      'quality' => 80,
      'optimize_images' => true,
      'create_thumbnails' => true,
      'use_imagick' => null,
      // Video settings
      'extract_video_thumbnail' => true,
      'video_thumbnail_time' => 0,
      // Document settings
      'extract_document_preview' => false,
      // Directory structure
      'directory_pattern' => '{user_id}/{post_type}',
      // General
      'original_retention' => 2592000, // 30 days
   ];
        // Schedule cleanup of original files
//        if (!wp_next_scheduled('jvb_cleanup_original_uploads')) {
//            wp_schedule_event(time(), 'daily', 'jvb_cleanup_original_uploads');
//        }
//        add_action('jvb_cleanup_original_uploads', [$this, 'cleanupOriginalFiles']);
        // Track upload statistics
        add_action('jvb_upload_complete', [$this, 'trackUploadStats']);
        add_action('jvb_upload_failed', [$this, 'track_failed_upload']);
    }
    /**
     * @param array $file_data
     *
     * @return array
     * @throws Exception
     */
   public function secureUploadedFile(array $file_data): array
   protected string $upload_dir;
   protected string $upload_url;
   public function __construct()
   {
      // Create a persistent temporary directory if it doesn't exist
      $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$this->user_id}";
      wp_mkdir_p($temp_dir);
      $upload = wp_upload_dir();
      $this->upload_dir = $upload['basedir'];
      $this->upload_url = $upload['baseurl'];
      // Generate a unique filename for temporary storage
      $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
      $temp_path = "{$temp_dir}/{$temp_filename}";
      // Move the uploaded file to our temporary storage
      if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
         // Fallback to copy if needed
         if (!copy($file_data['tmp_name'], $temp_path)) {
            throw new Exception('Failed to store uploaded file');
         }
      }
      // Return metadata about the stored file
      return [
         'temp_path' => $temp_path,
         'original_name' => $file_data['name'],
         'mime_type' => $file_data['type'],
         'file_size' => $file_data['size'],
         'stored_at' => current_time('mysql')
      ];
   }
    /**
     * @param string $temp_path
     * @param string $post_type
     * @param int $post_id
     * @param int $term_id
     * @param string $original_filename
     *
     * @return array
     * @throws Exception
     */
   public function processImageFromStorage(string $temp_path, string $post_type, int $post_id = 0, int $term_id = 0, string $original_filename = ''): array
   {
      if (!file_exists($temp_path)) {
         throw new Exception('Stored file no longer exists: ' . $temp_path);
      }
      $this->term_id = $term_id;
      $this->post_type = $post_type;
      // Get base upload directory based on content type
      $rel_path = $this->getUploadDirectory();
      // Ensure the directory exists
      $full_upload_path = $this->upload_dir . '/' . $rel_path;
      if (!wp_mkdir_p($full_upload_path)) {
         throw new Exception("Failed to create upload directory: {$full_upload_path}");
      }
      // Generate filename WITHOUT extension (generateFilename should not include extension)
      $base_filename = $this->generateFilename($original_filename, get_userdata($this->user_id));
      // Process the image directly from our temporary storage
      return $this->processImage($temp_path, $rel_path, $base_filename, $post_id);
   }
    /**
     * @return void
     * @throws Exception
     */
    protected function setupUploadDirs():void
    {
        $upload_info = wp_upload_dir();
        if ($upload_info['error']) {
            throw new Exception($upload_info['error']);
        }
        $this->upload_dir = $upload_info['basedir'];
        $this->upload_url = $upload_info['baseurl'];
    }
    /**
     * @return string
     */
    public function getUploadDirectory():string
    {
      // Default WordPress organization: year/month
      $default_path = date('Y/m');
      /**
       * Filter the upload directory structure
       *
       * @param string $path The default upload path
       * @param string $post_type The post type being uploaded
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_directory', $default_path, $this->post_type, $this->user_id, $this->term_id);
    }
   /**
    * Generate filename with extensible filtering
    * Default: generic SEO-friendly format
    *
    * @param string $original_name
    * @param object $user_data
    * @return string
    */
   public function generateFilename(string $original_name, object $user_data): string
   {
      // Default generic filename: {post_type}-{user_id}-{counter}
      $filename = sprintf(
         '%s-%s',
         sanitize_title($this->post_type),
         $this->user_id
      );
      /**
       * Filter the generated filename (without extension)
       *
       * @param string $filename The generated filename
       * @param string $original_name The original filename
       * @param object $user_data WordPress user data object
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_filename', $filename, $original_name, $user_data, $this->post_type, $this->user_id, $this->term_id).'-'.$this->getNextFileCounter();
   }
    /**
     * @return string
     */
   protected function getNextFileCounter(): string
   {
      // Get counter key for this post type
      $counter_key = 'upload_counter_' . str_replace(['/', '\\'], '_', $this->post_type);
      // Get current counter value, default to 0
      $counter = (int)get_user_meta($this->user_id, $counter_key, true) ?: 0;
      // Increment counter
      $counter++;
      // Update counter in user meta
      update_user_meta($this->user_id, $counter_key, $counter);
      // Return formatted counter
      return sprintf('%08d', $counter);
   }
   /**
    * Generate alt text with filtering for customization
    * Default: basic or empty alt text
    *
    * @param string $file
    * @param object $user_data
    * @param int|null $post_id
    * @return string
    */
   protected function generateAltText(string $file, object $user_data, int|null $post_id = null): string
   {
      // Default: basic alt text or empty
      $alt_text = '';
      /**
       * Filter the generated alt text
       *
       * @param string $alt_text The generated alt text
       * @param string $file The file path
       * @param object $user_data WordPress user data object
       * @param int|null $post_id The post ID (if applicable)
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_alt_text', $alt_text, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
      // Check for Imagick
      $this->defaultConfig['use_imagick'] = extension_loaded('imagick');
   }
   /**
    * Generate image title with filtering for customization
    * Default: WordPress default behavior (filename-based)
    *
    * @param string $file
    * @param object $user_data
    * @param int|null $post_id
    * @return string
    * Initial, basic processing of upload data for later processing through OperationQueue.php
    * @param array $file_data
    * @param array $context
    * @return array
    */
   protected function generateImageTitle(string $file, object $user_data, int|null $post_id = null): string
   {
      // Default: Use filename without extension (WordPress default behavior)
      $title = pathinfo($file, PATHINFO_FILENAME);
      /**
       * Filter the generated image title
       *
       * @param string $title The generated title
       * @param string $file The file path
       * @param object $user_data WordPress user data object
       * @param int|null $post_id The post ID (if applicable)
       * @param string $post_type The post type
       * @param int $user_id The user ID
       * @param int $term_id The term ID (if applicable)
       */
      return apply_filters('jvb_upload_image_title', $title, $file, $user_data, $post_id, $this->post_type, $this->user_id, $this->term_id);
   }
    /**
     * @param array $file_data
     * @param array $options
     *
     * @return array|WP_Error
     */
   public function handleContentUpload(array $file_data, array $options = []): array|WP_Error
   public function secureUploadedFile(array $file_data, array $context): array
   {
      try {
         if (!isset($file_data['tmp_name']) || !is_uploaded_file($file_data['tmp_name'])) {
            throw new Exception('No valid file uploaded.');
         // Validate file upload
         if (!isset($file_data['tmp_name']) || $file_data['error'] !== UPLOAD_ERR_OK) {
            throw new Exception('Invalid file upload');
         }
         $user_data = get_userdata($this->user_id);
         $post_id = $options['post_id'] ?? 0;
         $this->term_id = $options['term_id'] ?? 0;
         $mime_type = mime_content_type($file_data['tmp_name']);
         $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
         if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
         }
         // Get base upload directory based on content type
         $base_dir = $this->getUploadDirectory();
         $original_dir = 'originals';
         // Check allowed types if specified
         if ($this->defaultConfig['allowed_types'] && !in_array($mime_type, $this->defaultConfig['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
         }
         $rel_path = $base_dir;
         $this->ensureUploadDirs($rel_path, $original_dir);
         $temp_dir = $this->generateDirectory($file_type, "{user_id}/temp", $context);
         $filename = $this->generateFilename($file_data['name'], $user_data);
         // Ensure directories exist
         $this->ensureDirectories($temp_dir);
         // Store original file
         $original_file = $this->storeOriginalFile(
            $file_data['tmp_name'],
            $base_dir,
            $original_dir,
            $filename
         );
         // Generate a unique filename for temporary storage
         $temp_filename = uniqid() . '_' . sanitize_file_name($file_data['name']);
         $temp_path = "{$this->upload_dir}/{$temp_dir}/{$temp_filename}";
         // Process immediately
         return $this->processImage($original_file, $rel_path, $filename, $post_id);
         // Move the uploaded file to our temporary storage
         if (!move_uploaded_file($file_data['tmp_name'], $temp_path)) {
            // Fallback to copy if needed
            if (!copy($file_data['tmp_name'], $temp_path)) {
               throw new Exception('Failed to store uploaded file');
            }
         }
         // Return metadata about the stored file
         return [
            'temp_path' => $temp_path,
            'original_name' => $file_data['name'],
            'mime_type' => $file_data['type'],
            'file_type' => $file_type,
            'file_size' => $file_data['size'],
            'stored_at' => current_time('mysql')
         ];
      } catch (Exception $e) {
         JVB()->error()->log('Failed to store uploaded file: ' . print_r($e->getMessage(), true));
         return [];
      }
   }
   /**
    * Process an uploaded file with full context
    *
    * @param string $temp_path
    * @param array $context Upload context and configuration
    * @return array|WP_Error Result with attachment_id, url, file path
    */
   public function processUpload(string $temp_path, array $context): array|WP_Error
   {
      try {
         // Validate temp file exists
         if (!file_exists($temp_path)) {
            throw new Exception('Temporary file not found: ' . $temp_path);
         }
         // Validate MIME type
         $mime_type = mime_content_type($temp_path);
         if (!$this->validateMimeType($temp_path, $mime_type)) {
            throw new Exception('Invalid or potentially dangerous file type');
         }
         $file_type = $this->allowedTypes[$mime_type] ?? 'unknown';
         if ($file_type === 'unknown') {
            throw new Exception('Unsupported file type: ' . $mime_type);
         }
         // Merge config
         $config = array_merge($this->defaultConfig, $context['config'] ?? [], $context);
         // Extract context
         $user_id = $context['user_id'] ?? get_current_user_id();
         $post_id = $context['post_id'] ?? 0;
         // Check allowed types if specified
         if (!empty($config['allowed_types']) && !in_array($mime_type, $config['allowed_types'])) {
            throw new Exception('File type not allowed: ' . $mime_type);
         }
         // Validate file size
         $this->validateFileSize($temp_path, $file_type, $config);
         // Generate directory structure for final storage
         $directory = $this->generateDirectory($file_type, $config['directory_pattern'] ?? '{user_id}/{content}/{file_type}', $context);
         // Ensure directories exist
         $this->ensureDirectories($directory);
         // Generate filename
         $original_name = $context['original_name'] ?? basename($temp_path);
         $filename = $this->generateFilename($original_name, $context);
         // Store original from temp
         $original_path = $this->storeOriginalFromTemp($temp_path, $directory, $filename);
         // Process based on file type
         return match($file_type) {
            'image' => $this->processImage($original_path, $directory, $filename, $post_id, $config, $context),
            'video' => $this->processVideo($original_path, $directory, $filename, $post_id, $config, $context),
            'document' => $this->processDocument($original_path, $directory, $filename, $post_id, $config, $context),
            default => throw new Exception('Unknown file type')
         };
      } catch (Exception $e) {
         JVB()->error()->log(
            '[UploadManager]:processUpload',
            $e->getMessage(),
            ['temp_path' => $temp_path, 'context' => $context]
         );
         return new WP_Error('upload_failed', $e->getMessage());
      }
   }
    /**
     * @param string $rel_path
     * @param string $original_dir
     *
     * @return void
     * @throws Exception
     */
   protected function ensureUploadDirs($rel_path, $original_dir): void
   /**
    * Store original file from temp location (not from PHP upload)
    */
   protected function storeOriginalFromTemp(string $temp_path, string $directory, string $filename): string
   {
      $ext = pathinfo($temp_path, PATHINFO_EXTENSION);
      $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
      // Copy from temp to final location
      if (!copy($temp_path, $original_path)) {
         throw new Exception('Failed to store original file');
      }
      return $original_path;
   }
   /**
    * Clean up empty temporary directories
    */
   public function cleanupEmptyTempDirs(int $user_id): void
   {
      $temp_base = "{$this->upload_dir}/{$user_id}/temp";
      if (!is_dir($temp_base)) {
         return;
      }
      // Get all subdirectories
      $dirs = glob($temp_base . '/*', GLOB_ONLYDIR);
      foreach ($dirs as $dir) {
         // Check if directory is empty
         $files = scandir($dir);
         $files = array_diff($files, ['.', '..']);
         if (empty($files)) {
            @rmdir($dir);
         }
      }
      // Try to remove temp base if empty
      $files = scandir($temp_base);
      $files = array_diff($files, ['.', '..']);
      if (empty($files)) {
         @rmdir($temp_base);
      }
   }
   /**
    * Generate directory structure based on pattern
    */
   protected function generateDirectory(string $file_type, string $pattern, array $context): string
   {
      $replacements = [
         '{user_id}' => $context['user_id'] ?? get_current_user_id(),
         '{post_type}' => $context['content'] ?? '',
         '{content}' => $context['content'] ?? '',
         '{file_type}' => $file_type,
         '{year}' => date('Y'),
         '{month}' => date('m'),
      ];
      $directory = str_replace(array_keys($replacements), array_values($replacements), $pattern);
      // Allow filtering
      return apply_filters('jvb_upload_directory', $directory, $file_type, $context);
   }
   /**
    * Ensure directory structure exists
    */
   protected function ensureDirectories(string $rel_path): void
   {
      $dirs = [
         "{$this->upload_dir}/{$rel_path}",
         "{$this->upload_dir}/{$rel_path}/{$original_dir}"
         "{$this->upload_dir}/{$rel_path}/originals"
      ];
      foreach ($dirs as $dir) {
@@ -342,19 +299,13 @@
      }
   }
   /**
    * @param string $tmp_file
    * @param string $base_dir
    * @param string $original_dir
    * @param string $filename
    * @return string
    * @throws Exception
    * Store original file
    */
   protected function storeOriginalFile(string $tmp_file, string $base_dir, string $original_dir, string $filename): string
   protected function storeOriginal(string $tmp_file, string $directory, string $filename): string
   {
      $ext = pathinfo($tmp_file, PATHINFO_EXTENSION);
      $original_path = "{$this->upload_dir}/{$base_dir}/{$original_dir}/{$filename}.{$ext}";
      $original_path = "{$this->upload_dir}/{$directory}/originals/{$filename}.{$ext}";
      if (!move_uploaded_file($tmp_file, $original_path)) {
         throw new Exception('Failed to store original file');
@@ -364,275 +315,424 @@
   }
   /**
    * Process image with better error handling
    * Validate file size based on type and config
    */
   protected function processImage(string $original_file, string $rel_path, string $filename, int $post_id): array
   protected function validateFileSize(string $file_path, string $file_type, array $config): void
   {
      // Validate the original file still exists
      if (!file_exists($original_file)) {
         throw new Exception('Original file no longer exists: ' . $original_file);
      $size = filesize($file_path);
      $max_size = $config['max_size'][$file_type] ?? $this->defaultConfig['max_size'][$file_type];
      if ($size > $max_size) {
         $max_mb = round($max_size / 1048576, 2);
         $actual_mb = round($size / 1048576, 2);
         throw new Exception("File too large: {$actual_mb}MB (max: {$max_mb}MB)");
      }
   }
   protected function validateMimeType(string $file_path, string $declared_type): bool
   {
      // Get actual MIME type
      $actual_type = mime_content_type($file_path);
      // Check if actual type is allowed
      if (!array_key_exists($actual_type, $this->allowedTypes)) {
         return false;
      }
      // Verify file type before processing
      $mime_type = mime_content_type($original_file);
      if (!in_array($mime_type, $this->config['allowed_types'])) {
         throw new Exception('Invalid file type detected during processing: ' . $mime_type);
      // For images, verify it's actually an image
      if (str_starts_with($actual_type, 'image/')) {
         $image_info = @getimagesize($file_path);
         if ($image_info === false) {
            return false;
         }
      }
      // Ensure the upload directory exists
      $full_upload_dir = $this->upload_dir . '/' . $rel_path;
      if (!wp_mkdir_p($full_upload_dir)) {
         throw new Exception("Failed to create upload directory: {$full_upload_dir}");
      return true;
   }
   /**
    * Process image file
    * @throws Exception
    */
   protected function processImage(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   {
      $full_dir = "{$this->upload_dir}/{$directory}";
      $original_mime = mime_content_type($original_path);
      // Determine if conversion is needed
      $should_convert = false;
      $target_format = false;
      if ($config['convert'] && $config['convert'] !== false) {
         $target_format = strtolower($config['convert']);
         // Validate format
         if (!isset($this->supportedFormats[$target_format])) {
            throw new Exception("Unsupported conversion format: {$target_format}");
         }
         // Check if conversion is needed
         $target_mime = $this->supportedFormats[$target_format];
         $should_convert = ($original_mime !== $target_mime);
      }
      // Convert to WebP if enabled
      if ($this->config['convert_to_webp'] && $mime_type !== 'image/webp') {
         $final_path = "{$full_upload_dir}/{$filename}.webp";
      // Convert or copy based on configuration
      if ($should_convert) {
         $final_path = "{$full_dir}/{$filename}.{$target_format}";
         if ($this->config['use_imagick']) {
            $this->convertWithImagick($original_file, $final_path);
         } else {
            $this->convertWithGd($original_file, $final_path);
         try {
            $this->convertImage(
               $original_path,
               $final_path,
               $target_format,
               $config['quality'],
               $config['use_imagick']
            );
         } catch (Exception $e) {
            JVB()->error()->log('Image conversion failed: ' . print_r($e->getMessage(), true));
            // Fallback to original
            $ext = pathinfo($original_path, PATHINFO_EXTENSION);
            $final_path = "{$full_dir}/{$filename}.{$ext}";
            copy($original_path, $final_path);
         }
      } else {
         // Just copy the original with its extension
         $original_ext = pathinfo($original_file, PATHINFO_EXTENSION);
         $final_path = "{$full_upload_dir}/{$filename}.{$original_ext}";
         if (!copy($original_file, $final_path)) {
            throw new Exception("Failed to copy file from {$original_file} to {$final_path}");
         }
         // Keep original format
         $ext = pathinfo($original_path, PATHINFO_EXTENSION);
         $final_path = "{$full_dir}/{$filename}.{$ext}";
         copy($original_path, $final_path);
      }
      // Verify the final file was created
      if (!file_exists($final_path)) {
         throw new Exception("Final processed file was not created: {$final_path}");
      // Create attachment
      $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      // Set alt text if provided
      $alt_text = apply_filters('jvb_upload_alt_text', '', $context);
      if ($alt_text !== '') {
         update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text);
      }
      // Generate title text
      $title = $this->generateImageTitle(
         $final_path,
         get_userdata($this->user_id),
         $post_id
      );
      // Create attachment with title
      $attachment_id = $this->createAttachment($final_path, $title, $post_id);
      // Generate thumbnails
      if ($this->config['create_thumbnails']) {
      if ($config['create_thumbnails']) {
         $this->generateThumbnails($attachment_id);
      }
      // Update post attachments with new file info
      $this->updatePostAttachments($attachment_id, $final_path);
      return [
         'success' => true,
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path
         'file' => $final_path,
         'type' => 'image',
         'format' => $target_format ?: pathinfo($final_path, PATHINFO_EXTENSION)
      ];
   }
    /**
     * @param string $source
     * @param string $destination
     *
     * @return void
     * @throws Exception
     */
   protected function convertWithImagick(string $source, string $destination, string $toType = 'webp'): void
   /**
    * Process video file
    * @throws Exception
    */
   protected function processVideo(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   {
      $allowed = ['webp', 'jpeg', 'jpg', 'png'];
      if (!in_array($toType, $allowed)) {
         return;
      }
      $full_dir = "{$this->upload_dir}/{$directory}";
      $ext = pathinfo($original_path, PATHINFO_EXTENSION);
      $final_path = "{$full_dir}/{$filename}.{$ext}";
      // Copy video to final location
      copy($original_path, $final_path);
      // Create attachment
      try {
         $image = new Imagick($source);
         $image->setImageFormat('webp');
         $image->setImageCompressionQuality($this->config['webp_quality']);
         $image->writeImage($destination);
         $image->clear();
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         throw new Exception('WebP conversion with Imagick failed: ' . $e->getMessage());
         JVB()->error()->log('Attachment creation failed: '.print_r($e->getMessage(), true));
         return ['success' => false];
      }
      // Extract thumbnail if enabled
      if ($config['extract_video_thumbnail']) {
         $this->extractVideoThumbnail($attachment_id, $final_path, $config['video_thumbnail_time']);
      }
      return [
         'success' => true,
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path,
         'type' => 'video'
      ];
   }
   /**
    * Process document file
    */
   protected function processDocument(string $original_path, string $directory, string $filename, int $post_id, array $config, array $context): array
   {
      $full_dir = "{$this->upload_dir}/{$directory}";
      $ext = pathinfo($original_path, PATHINFO_EXTENSION);
      $final_path = "{$full_dir}/{$filename}.{$ext}";
      // Copy document to final location
      copy($original_path, $final_path);
      // Create attachment
      try {
         $attachment_id = $this->createAttachment($final_path, $filename, $context, $post_id);
      } catch (Exception $e) {
         JVB()->error()->log('Conversion failed: '.print_r($e->getMessage(), true));
         return ['success' => false];
      }
      return [
         'success' => true,
         'attachment_id' => $attachment_id,
         'url' => wp_get_attachment_url($attachment_id),
         'file' => $final_path,
         'type' => 'document'
      ];
   }
   /**
    * Generate SEO-friendly filename
    */
   protected function generateFilename(string $original_name, array $context): string
   {
      $name_parts = pathinfo($original_name);
      $base_name = sanitize_title($name_parts['filename']);
      $user_data = array_key_exists('user_id', $context) ? get_userdata((int)$context['user_id']) : get_current_user();
      $username = $user_data ? sanitize_title($user_data->display_name) : 'user';
      $timestamp = time();
      return apply_filters(
         'jvb_upload_filename',
         "{$base_name}-{$timestamp}",
         $context
      );
   }
   protected function sanitizeFileName(string $filename): string
   {
      // Remove path info
      $filename = basename($filename);
      // Remove special characters
      $filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
      // Remove multiple underscores
      $filename = preg_replace('/_+/', '_', $filename);
      // Ensure it's not too long (255 char limit in most filesystems)
      if (strlen($filename) > 200) {
         $ext = pathinfo($filename, PATHINFO_EXTENSION);
         $name = pathinfo($filename, PATHINFO_FILENAME);
         $name = substr($name, 0, 200 - strlen($ext) - 1);
         $filename = $name . '.' . $ext;
      }
      return $filename;
   }
   /**
    * Create WordPress attachment
    */
   protected function createAttachment(string $file_path, string $title, array $context, int $post_id = 0): int
   {
      $file_url = str_replace($this->upload_dir, $this->upload_url, $file_path);
      $mime_type = mime_content_type($file_path);
      $title = apply_filters('jvb_upload_title', $title, $file_path, $context);
      $attachment_id = wp_insert_attachment([
         'guid' => $file_url,
         'post_mime_type' => $mime_type,
         'post_title' => $title,
         'post_status' => 'inherit'
      ], $file_path, $post_id);
      if (is_wp_error($attachment_id)) {
         throw new Exception('Failed to create attachment');
      }
      require_once(ABSPATH . 'wp-admin/includes/image.php');
      $metadata = wp_generate_attachment_metadata($attachment_id, $file_path);
      wp_update_attachment_metadata($attachment_id, $metadata);
      return $attachment_id;
   }
   /**
    * Convert an existing attachment to a different format
    *
    * @param int $attachment_id WordPress attachment ID
    * @param string $format Target format: 'webp', 'jpeg', 'png', 'gif'
    * @param int $quality Conversion quality (1-100)
    * @param bool $replace Replace original file (default: true)
    * @return array|WP_Error Result with new attachment details
    */
   public function convertImageTo(int $attachment_id, string $format, int $quality = 80, bool $replace = true): array|WP_Error
   {
      try {
         // Validate format
         $format = strtolower($format);
         if (!isset($this->supportedFormats[$format])) {
            throw new Exception("Unsupported format: {$format}");
         }
         // Get original file
         $original_path = get_attached_file($attachment_id);
         if (!$original_path || !file_exists($original_path)) {
            throw new Exception('Original file not found');
         }
         // Check if it's an image
         $mime_type = get_post_mime_type($attachment_id);
         if (!str_starts_with($mime_type, 'image/')) {
            throw new Exception('Attachment is not an image');
         }
         // Don't convert if already in target format
         $current_mime = $this->supportedFormats[$format];
         if ($mime_type === $current_mime) {
            return [
               'success' => true,
               'message' => 'Already in target format',
               'attachment_id' => $attachment_id,
               'url' => wp_get_attachment_url($attachment_id)
            ];
         }
         // Generate new filename
         $path_info = pathinfo($original_path);
         $new_filename = $path_info['filename'] . '.' . $format;
         $new_path = $path_info['dirname'] . '/' . $new_filename;
         // Perform conversion
         $use_imagick = $this->defaultConfig['use_imagick'];
         $this->convertImage($original_path, $new_path, $format, $quality, $use_imagick);
         if ($replace) {
            // Replace the original file
            $this->replaceAttachmentFile($attachment_id, $new_path, $current_mime);
            $result_attachment_id = $attachment_id;
         } else {
            // Create new attachment
            $post_id = wp_get_post_parent_id($attachment_id);
            $result_attachment_id = $this->createAttachment(
               $new_path,
               get_the_title($attachment_id),
               ['user_id' => get_post_field('post_author', $attachment_id)],
               $post_id
            );
         }
         // Regenerate thumbnails
         $this->generateThumbnails($result_attachment_id);
         return [
            'success' => true,
            'attachment_id' => $result_attachment_id,
            'url' => wp_get_attachment_url($result_attachment_id),
            'format' => $format,
            'file' => $new_path
         ];
      } catch (Exception $e) {
         return new WP_Error('conversion_failed', $e->getMessage());
      }
   }
    /**
     * Fixed convertWithGd method with better error handling
     */
   protected function convertWithGd(string $source, string $destination, string $toType = 'webp'): void
   /**
    * Generic image conversion method
    */
   protected function convertImage(string $source, string $destination, string $format, int $quality, bool $use_imagick): void
   {
      if ($use_imagick) {
         $this->convertWithImagick($source, $destination, $format, $quality);
      } else {
         $this->convertWithGd($source, $destination, $format, $quality);
      }
   }
   /**
    * Convert image with Imagick (supports multiple formats)
    */
   protected function convertWithImagick(string $source, string $destination, string $format, int $quality): void
   {
      try {
         $image = new Imagick($source);
         // Set format
         $image->setImageFormat($format);
         // Set quality
         $image->setImageCompressionQuality($quality);
         // Format-specific optimizations
         if ($format === 'webp') {
            $image->setOption('webp:method', '6'); // Better compression
         } elseif ($format === 'jpeg' || $format === 'jpg') {
            $image->setImageCompression(Imagick::COMPRESSION_JPEG);
         } elseif ($format === 'png') {
            $image->setImageCompression(Imagick::COMPRESSION_ZIP);
         }
         $image->writeImage($destination);
         $image->clear();
         $image->destroy();
      } catch (Exception $e) {
         throw new Exception("Imagick conversion to {$format} failed: " . $e->getMessage());
      }
   }
   /**
    * Convert image with GD (supports multiple formats)
    */
   protected function convertWithGd(string $source, string $destination, string $format, int $quality): void
   {
      $mime_type = mime_content_type($source);
      // Ensure destination directory exists
      $dest_dir = dirname($destination);
      if (!wp_mkdir_p($dest_dir)) {
         throw new Exception("Failed to create destination directory: {$dest_dir}");
      // Create image resource from source
      $image = match($mime_type) {
         'image/jpeg' => imagecreatefromjpeg($source),
         'image/png' => imagecreatefrompng($source),
         'image/gif' => imagecreatefromgif($source),
         'image/webp' => imagecreatefromwebp($source),
         default => throw new Exception('Unsupported source image type for GD conversion')
      };
      if (!$image) {
         throw new Exception('Failed to create image resource');
      }
      switch ($mime_type) {
         case 'image/webp':
            $image = imagecreatefromwebp($source);
            break;
         case 'image/jpeg':
            $image = imagecreatefromjpeg($source);
            break;
         case 'image/png':
            $image = imagecreatefrompng($source);
            if ($image !== false) {
               imagepalettetotruecolor($image);
               imagealphablending($image, true);
               imagesavealpha($image, true);
            }
            break;
         case 'image/gif':
            $image = imagecreatefromgif($source);
            if ($image !== false) {
               imagepalettetotruecolor($image);
            }
            break;
         default:
            throw new Exception('Unsupported image type for GD conversion: ' . $mime_type);
      }
      // Convert to target format
      $result = match($format) {
         'webp' => imagewebp($image, $destination, $quality),
         'jpeg', 'jpg' => imagejpeg($image, $destination, $quality),
         'png' => imagepng($image, $destination, $this->qualityToPngCompression($quality)),
         'gif' => imagegif($image, $destination),
         default => throw new Exception("Unsupported target format for GD: {$format}")
      };
      if ($image === false) {
         throw new Exception('Failed to create image resource from source file: ' . $source);
      }
      // Convert to WebP
      $result = imagewebp($image, $destination, $this->config['webp_quality']);
      // Clean up memory
      imagedestroy($image);
      if (!$result) {
         throw new Exception('WebP conversion with GD failed - imagewebp returned false');
      }
      // Verify the file was actually created
      if (!file_exists($destination)) {
         throw new Exception('WebP file was not created despite imagewebp returning true');
         throw new Exception("GD conversion to {$format} failed");
      }
   }
   /**
    * @return void
    * Convert quality (1-100) to PNG compression level (0-9)
    */
   public function cleanupOriginalFiles(): void
   protected function qualityToPngCompression(int $quality): int
   {
      $cutoff = time() - $this->config['original_retention'];
      // Get upload directory and find original directories
      $pattern = "{$this->upload_dir}/**/originals";
      $original_dirs = glob($pattern, GLOB_ONLYDIR);
      foreach ($original_dirs as $original_dir) {
         $files = glob("{$original_dir}/*");
         foreach ($files as $file) {
            if (filemtime($file) < $cutoff) {
               unlink($file);
            }
         }
      }
      // PNG compression is inverted: 0 = no compression, 9 = max compression
      // Quality: 100 = best quality, 1 = worst quality
      // So: quality 100 -> compression 0, quality 1 -> compression 9
      return (int) round((100 - $quality) / 11.11);
   }
   /**
    * @param string|null $temp_dir
    * @return void
    */
   protected function cleanupTempFiles(string|null $temp_dir = null): void
   {
      if (is_null($temp_dir)) {
         $temp_dir = $this->upload_dir . '/tmp';
      }
      if (is_dir($temp_dir)) {
         $files = glob($temp_dir . '/*');
         foreach ($files as $file) {
            if (is_file($file)) {
               @unlink($file);
            }
         }
         @rmdir($temp_dir);
      }
   }
   /**
    * @param int $retention_days
    * @return void
    */
   public function cleanupUserFiles(int $retention_days = 30): void
   {
      $cutoff = time() - ($retention_days * DAY_IN_SECONDS);
      $user_dir = $this->getUploadDirectory();
      if (!is_dir($user_dir)) {
         return;
      }
      $this->cleanupDirectory($user_dir, $cutoff);
   }
    /**
    * @param string $dir
    * @param int $cutoff
    * @return void
    */
   protected function cleanupDirectory(string $dir, int $cutoff): void
   {
      $files = glob($dir . '/*');
      foreach ($files as $file) {
         if (is_dir($file)) {
            $this->cleanupDirectory($file, $cutoff);
         } elseif (filemtime($file) < $cutoff) {
            @unlink($file);
         }
      }
   }
   /**
    * @param string $file
    * @param string $title
    * @param int $post_id
    * @return int|WP_Error
    * @throws Exception
    */
   protected function createAttachment(string $file, string $title, int $post_id): int|WP_Error
   {
      $file_url = str_replace($this->upload_dir, $this->upload_url, $file);
      $attachment = [
         'post_mime_type' => mime_content_type($file),
         'post_title' => $title,
         'post_name' => sanitize_title($title),
         'post_content' => '',
         'post_status' => 'inherit',
         'guid' => $file_url
      ];
      $attach_id = wp_insert_attachment($attachment, $file, $post_id);
      if (is_wp_error($attach_id)) {
         throw new Exception($attach_id->get_error_message());
      }
      // Generate and set alt text
      $alt_text = $this->generateAltText($file, get_userdata($this->user_id), $post_id);
      update_post_meta($attach_id, '_wp_attachment_image_alt', $alt_text);
      return $attach_id;
   }
   /**
    * Generate thumbnails using WordPress's built-in image size system
    * This will create all registered image sizes (thumbnail, medium, large, and any custom sizes)
    * Sites can register custom image sizes using add_image_size()
    *
    * @param int $attachment_id
    * @return void
    * Generate image thumbnails
    */
   protected function generateThumbnails(int $attachment_id): void
   {
@@ -642,119 +742,13 @@
   }
   /**
    * @param $result
    * @return void
    * Extract video thumbnail (placeholder - requires ffmpeg)
    */
   protected function trackUploadStats($result)
   protected function extractVideoThumbnail(int $attachment_id, string $video_path, int $time): void
   {
      $stats_key = "upload_stats_{$this->user_id}";
      $stats = wp_cache_get($stats_key) ?: [
         'total_uploads' => 0,
         'successful_uploads' => 0,
         'failed_uploads' => 0,
         'total_size' => 0,
         'last_upload' => null
      ];
      if ($result['success']) {
         $stats['successful_uploads']++;
         $stats['total_size'] += filesize($result['file']);
      } else {
         $stats['failed_uploads']++;
      }
      $stats['total_uploads']++;
      $stats['last_upload'] = current_time('mysql');
      wp_cache_set($stats_key, $stats, '', DAY_IN_SECONDS);
      // This would require ffmpeg or similar
      // Placeholder for future implementation
      apply_filters('jvb_extract_video_thumbnail', null, $attachment_id, $video_path, $time);
   }
   /**
    * @param int $attachment_id
    * @param string $new_file_path
    * @return void
    */
   protected function updatePostAttachments(int $attachment_id, string $new_file_path): void
   {
      // Update attachment post
      $file_url = str_replace($this->upload_dir, $this->upload_url, $new_file_path);
      $filename = basename($new_file_path);
      wp_update_post([
         'ID' => $attachment_id,
         'guid' => $file_url,
         'post_mime_type' => mime_content_type($new_file_path),
         'post_title' => $filename
      ]);
      // Update attachment metadata
      update_post_meta($attachment_id, '_wp_attached_file', str_replace($this->upload_dir . '/', '', $new_file_path));
      // Update attachment metadata including sizes
      $metadata = wp_get_attachment_metadata($attachment_id);
      if ($metadata) {
         $metadata['file'] = str_replace($this->upload_dir . '/', '', $new_file_path);
         // Update thumbnail paths if they exist
         if (!empty($metadata['sizes'])) {
            foreach ($metadata['sizes'] as $size => $info) {
               $old_file = $info['file'];
               $new_file = preg_replace(
                  '/\.(jpe?g|png|gif)$/i',
                  '.webp',
                  $old_file
               );
               $metadata['sizes'][$size]['file'] = $new_file;
               $metadata['sizes'][$size]['mime-type'] = 'image/webp';
            }
         }
         wp_update_attachment_metadata($attachment_id, $metadata);
      }
      // If this is a profile/featured image, update those references
      $post_id = wp_get_post_parent_id($attachment_id);
      if ($post_id) {
         $featured_image_id = get_post_thumbnail_id($post_id);
         if ($featured_image_id === $attachment_id) {
            // Re-set the featured image to trigger any necessary updates
            set_post_thumbnail($post_id, $attachment_id);
         }
      }
      // Clear any caches
      clean_attachment_cache($attachment_id);
      clean_post_cache($post_id);
   }
   /**
    * Clean up empty temporary directories
    *
    * @param int $user_id
    * @return void
    */
   public function cleanupEmptyTempDirs(int $user_id): void
   {
      $temp_dir = "{$this->upload_dir}/jvb_temp_uploads/{$user_id}";
      if (is_dir($temp_dir)) {
         // Check if directory is empty
         $files = scandir($temp_dir);
         $is_empty = (count($files) <= 2); // Only . and .. entries
         if ($is_empty) {
            // Try to remove the empty directory
            @rmdir($temp_dir);
            // Also check if parent temp directory is empty
            $parent_temp_dir = "{$this->upload_dir}/jvb_temp_uploads";
            $parent_files = scandir($parent_temp_dir);
            $parent_is_empty = (count($parent_files) <= 2); // Only . and .. entries
            if ($parent_is_empty) {
               @rmdir($parent_temp_dir);
            }
         }
      }
   }
}