<?php

require_once plugin_dir_path( __FILE__ ) . '../config/CloudConstants.php';
/**
 * Cloud storage operations and migrations service
 * 
 * Main service class that handles all cloud storage operations including:
 * - File uploads and downloads between local storage and S3
 * - Cron-based batch migrations in both directions
 * - Progress tracking and migration lock management
 * - Retry operations for failed attachments
 * - WebP variant handling
 * - Multisite compatibility
 * 
 * @package    Upcasted_S3_Offload
 * @subpackage Upcasted_S3_Offload/admin/services
 * @since      3.0.0
 */
class CloudActions implements iCloudActions {
    // ========== CLASS PROPERTIES ==========
    /**
     * @var AmazonCloudManipulator
     */
    public $cloudManipulator;

    /**
     * @var string
     */
    private $bucket;

    /**
     * @var string
     */
    private $uploadDir;

    /**
     * @var CronManagement
     */
    private $cronManagement;

    private $loader;

    /**
     * @var string
     */
    private $removeFileFromCloudFlag = false;

    /**
     * @var string
     */
    private $removeFileFromLocalFlag = false;

    /**
     * @var string
     */
    private $cloudManipulatorBasePath;

    /**
     * @var array
     */
    protected $tempFilesToCleanup = [];

    /**
     * @var array
     * Stores mapping of original thumbnail filenames to renamed filenames when duplicates are detected
     * Format: [attachment_id => [old_filename => new_filename]]
     * STATIC so it persists across multiple CloudActions instances (singleton pattern is broken somewhere)
     */
    private static $thumbnailRenameMapping = [];

    /**
     * @var array
     * Tracks S3 paths already uploaded during current attachment processing to prevent duplicate uploads
     * WordPress can generate multiple sizes with same dimensions (e.g., large and medium_large both 768x1024)
     * STATIC so it persists across multiple CloudActions instances (singleton pattern is broken somewhere)
     */
    private static $uploadedThisAttachment = [];

    /**
     * @var int
     * Current attachment ID being processed
     * STATIC so it persists across multiple CloudActions instances
     */
    private static $currentAttachmentId = null;

    /**
     * @var array
     * Tracks dimensions of thumbnails generated during current attachment processing
     * Format: ['WIDTHxHEIGHT' => 'filename.ext']
     * Used to prevent WordPress from creating duplicate files with same dimensions
     */
    private static $generatedDimensions = [];

    /**
     * @var array
     * Stores main file basename mapping when renamed on S3
     * Format: [attachment_id => ['old' => 'original_basename', 'new' => 'renamed_basename']]
     * Used to adjust thumbnail/scaled filenames to match the renamed main file
     */
    private static $mainFileRenameMap = [];

    /**
     * @var array
     * Stores pending filename renames detected during sanitization (before attachment created)
     * Format: ['old_basename' => 'new_basename']
     * Used to adjust thumbnails when main file will be renamed
     */
    private static $pendingRenames = [];

    /**
     * @var int
     * Lock staleness threshold (seconds) - lock is stale if not refreshed within this time
     */
    private $migrationLockTtl;

    /**
     * @var int
     * Transient expiration (seconds) - how long WordPress keeps the lock in database
     */
    private $migrationLockExpiration;

    /**
     * @var int
     * Maximum retry attempts before marking as permanent failure
     */
    private $maxAttempts;

    // ============================================================
    // SECTION 1: CONSTRUCTOR AND INITIALIZATION
    // ============================================================
    /**
     * Get current upload directory, respecting multisite context
     * 
     * @since 3.0.0
     * @return string Upload directory path
     */
    private function get_current_upload_dir() : string {
        // Ensure we get the correct upload directory for the current site in multisite
        if ( is_multisite() ) {
            // Force refresh the upload directory for current site
            $upload_info = wp_upload_dir();
            return $upload_info['basedir'];
        }
        return wp_get_upload_dir()['basedir'];
    }

    /**
     * Convert full file path to site-relative path for multisite compatibility
     * 
     * Extracts relative path from full path, handling multisite sites/X/ structure.
     * Preserves multisite directory structure in S3 paths.
     * 
     * @since 3.0.0
     * @param string $full_path Full absolute file path
     * @return string|null Relative path from uploads root, or null if not in uploads
     */
    private function get_site_relative_path( string $full_path ) : ?string {
        // For multisite, we need to preserve the sites/X/ structure in S3 paths
        if ( is_multisite() ) {
            $upload_info = wp_upload_dir();
            $base_upload_dir = $upload_info['basedir'];
            // Get the WordPress uploads root (without site-specific path)
            $wp_upload_root = WP_CONTENT_DIR . '/uploads';
            // Calculate relative path from the WordPress uploads root to preserve sites/X/ structure
            if ( strpos( $full_path, $wp_upload_root . '/' ) === 0 ) {
                $relative_path = substr( $full_path, strlen( $wp_upload_root . '/' ) );
                return $relative_path;
            }
        }
        // Fallback for single site or if multisite path detection fails
        $current_upload_dir = $this->get_current_upload_dir();
        $path = explode( $current_upload_dir . '/', $full_path );
        $relative_path = ( isset( $path[1] ) ? $path[1] : null );
        return $relative_path;
    }

    /**
     * Initialize CloudActions service with cloud manipulator dependency
     * 
     * Sets up cloud storage client, bucket configuration, cron management,
     * migration lock parameters, and file retention flags.
     * 
     * @since 3.0.0
     * @param iCloudManipulator $cloudManipulator Cloud storage provider implementation (e.g., AmazonCloudManipulator)
     */
    public function __construct( iCloudManipulator $cloudManipulator ) {
        // Initialize lock timeout properties
        $this->migrationLockTtl = CloudConstants::LOCK_REFRESH_INTERVAL;
        $this->migrationLockExpiration = CloudConstants::LOCK_TIMEOUT;
        $this->maxAttempts = CloudConstants::MAX_RETRY_ATTEMPTS;
        if ( $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS ) ) {
            $this->cloudManipulator = new $cloudManipulator(
                $options[UPCASTED_S3_OFFLOAD_ACCESS_KEY_ID],
                $options[UPCASTED_S3_OFFLOAD_SECRET_ACCESS_KEY],
                $options[UPCASTED_OFFLOAD_REGION] ?? null,
                $options[UPCASTED_CUSTOM_ENDPOINT] ?? null
            );
            // Initialize upload directory (will be dynamically determined for multisite compatibility)
            $this->uploadDir = $this->get_current_upload_dir();
            $this->cronManagement = new CronManagement();
            $cloudToolsController = new CloudToolsController();
            $this->loader = new Upcasted_S3_Offload_Loader();
            if ( isset( $options[UPCASTED_S3_OFFLOAD_BUCKET] ) ) {
                $this->bucket = $options[UPCASTED_S3_OFFLOAD_BUCKET];
                $this->cloudManipulatorBasePath = $this->cloudManipulator->get_base_path();
            }
            if ( isset( $options[UPCASTED_KEEP_S3_FILES] ) ) {
                $this->removeFileFromCloudFlag = 'no' === $options[UPCASTED_KEEP_S3_FILES];
            }
            if ( isset( $options[UPCASTED_KEEP_LOCAL_FILES] ) ) {
                $this->removeFileFromLocalFlag = 'no' === $options[UPCASTED_KEEP_LOCAL_FILES];
            }
        }
    }

    // ============================================================
    // SECTION 2: BASIC MIGRATION OPERATIONS
    // ============================================================
    /**
     * Initialize plugin with bucket selection and settings
     * 
     * Saves bucket name, credentials, file retention options, auto-upload settings,
     * and allowed file types. Resets current instance with new configuration.
     * 
     * @since 3.0.0
     * @param string $bucket S3 bucket name to use for storage
     */
    public function upcasted_init( string $bucket ) {
        // Get existing options first to preserve settings like custom_domain
        $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, [] );
        if ( !empty( $_POST['access_key_id'] ) ) {
            $accessKey = sanitize_text_field( $_POST['access_key_id'] );
            $options[UPCASTED_S3_OFFLOAD_ACCESS_KEY_ID] = CloudCredentialsEncryption::getInstance()->encrypt( $accessKey );
        }
        if ( !empty( $_POST['secret_access_key'] ) ) {
            $secretAccess = sanitize_text_field( $_POST['secret_access_key'] );
            $options[UPCASTED_S3_OFFLOAD_SECRET_ACCESS_KEY] = CloudCredentialsEncryption::getInstance()->encrypt( $secretAccess );
        }
        if ( !empty( $_POST['region'] ) ) {
            $options[UPCASTED_OFFLOAD_REGION] = sanitize_post( $_POST['region'] );
        }
        if ( !empty( $_POST['custom_endpoint'] ) ) {
            $options[UPCASTED_CUSTOM_ENDPOINT] = sanitize_post( $_POST['custom_endpoint'] );
        }
        $options[UPCASTED_S3_OFFLOAD_BUCKET] = $bucket;
        update_option( UPCASTED_S3_OFFLOAD_SETTINGS, $options );
        $this->bucket = $bucket;
        $this->cloudManipulatorBasePath = $this->cloudManipulator->get_base_path();
        Upcasted_S3_Offload_Init::getInstance()->define_admin_hooks();
    }

    /**
     * Update file type and behavior settings (separate from S3 connection)
     * 
     * Saves file type filters and upload/delete behaviors independently
     * of the S3 connection credentials.
     * 
     * @since 3.1.0
     */
    public function upcasted_update_behavior_settings() {
        $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, [] );
        $includedFiletypes = ( !empty( $_POST['included_filetypes'] ) ? sanitize_post( $_POST['included_filetypes'] ) : null );
        if ( null !== $includedFiletypes ) {
            unset($includedFiletypes['ID']);
            unset($includedFiletypes['filter']);
            $options[UPCASTED_S3_OFFLOAD_INCLUDED_FILETYPES] = $includedFiletypes;
        }
        $options[UPCASTED_KEEP_LOCAL_FILES] = ( !empty( $_POST['keep_local_files'] ) ? sanitize_post( $_POST['keep_local_files'] ) : 'no' );
        $options[UPCASTED_KEEP_S3_FILES] = ( !empty( $_POST['keep_s3_files'] ) ? sanitize_post( $_POST['keep_s3_files'] ) : 'no' );
        $options[UPCASTED_AUTO_UPLOAD_TO_S3] = ( !empty( $_POST['auto_upload_to_s3'] ) ? sanitize_post( $_POST['auto_upload_to_s3'] ) : 'yes' );
        update_option( UPCASTED_S3_OFFLOAD_SETTINGS, $options );
    }

    /**
     * Replace local URLs with S3 URLs in responsive image srcset
     * 
     * WordPress filter hook that modifies srcset URLs for images stored on S3.
     * 
     * @since 3.0.0
     * @param array  $sources       Array of image sources
     * @param array  $size_array    Image size array
     * @param string $image_src     Image source URL
     * @param array  $image_meta    Image metadata
     * @param int    $attachment_id Attachment post ID
     * @return array Modified sources array with S3 URLs
     */
    public function upcasted_calculate_image_srcset(
        array $sources,
        array $size_array,
        string $image_src,
        array $image_meta,
        int $attachment_id
    ) {
        $metadata = wp_get_attachment_metadata( $attachment_id );
        if ( is_array( $metadata ) && array_key_exists( 'bucket', $metadata ) && isset( $metadata['file'] ) ) {
            $fileDir = pathinfo( $metadata['file'], PATHINFO_DIRNAME );
            // For multisite: check if metadata file path is missing sites/X/ prefix
            // WordPress sometimes stores edited image paths without the multisite prefix
            if ( is_multisite() && strpos( $fileDir, 'sites/' ) !== 0 ) {
                $blog_id = get_current_blog_id();
                $fileDir = "sites/{$blog_id}/{$fileDir}";
            }
            foreach ( $sources as $size => $source ) {
                // Extract just the filename from the source URL
                $filename = basename( $source['url'] );
                // Build the S3 URL with correct multisite path
                $sources[$size]['url'] = "{$this->cloudManipulatorBasePath}/{$fileDir}/{$filename}";
            }
        }
        return $sources;
    }

    /**
     * Upload intermediate image size to S3 during thumbnail generation
     * 
     * WordPress filter hook that intercepts intermediate size generation
     * to upload thumbnails to S3. Handles file rename mapping for duplicates.
     * 
     * @since 3.0.0
     * @param string $attachment_path Local file path of generated thumbnail
     * @return string S3 URL or original path
     */
    public function upcasted_image_make_intermediate_size( string $attachment_path ) : string {
        // Check if auto-upload to S3 is enabled
        $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, array() );
        $auto_upload_enabled = ( isset( $options[UPCASTED_AUTO_UPLOAD_TO_S3] ) ? $options[UPCASTED_AUTO_UPLOAD_TO_S3] : 'yes' );
        // Default to yes for backward compatibility
        // If auto-upload is disabled, return path without uploading to S3
        if ( $auto_upload_enabled === 'no' ) {
            return $attachment_path;
        }
        if ( $this->upcasted_check_mime_type( $attachment_path ) ) {
            $original_relative_path = $this->upcasted_get_uploaded_image_path( $attachment_path );
            $original_filename = basename( $attachment_path );
            // Use the attachment ID that was captured early (priority 1)
            // Don't use database lookup as it can find wrong attachment when filenames match
            $attachment_id = self::$currentAttachmentId;
            // Don't apply pending renames to thumbnails - WordPress already named them correctly
            // based on what our sanitize hook returned
            $applied_rename = false;
            // If main file was renamed, adjust thumbnail path to match the new base filename
            if ( !$applied_rename && $attachment_id && isset( self::$mainFileRenameMap[$attachment_id] ) ) {
                $rename_map = self::$mainFileRenameMap[$attachment_id];
                $old_basename = $rename_map['old'];
                $new_basename = $rename_map['new'];
                // Replace old basename with new basename in the path
                if ( strpos( $original_filename, $old_basename ) === 0 ) {
                    $new_filename = str_replace( $old_basename, $new_basename, $original_filename );
                    $original_relative_path = dirname( $original_relative_path ) . '/' . $new_filename;
                }
            }
            // Track dimensions to prevent duplicate files WITHIN THE SAME ATTACHMENT
            // Use attachment ID to scope the tracking - prevents false positives across different uploads
            $dimension_key = null;
            if ( file_exists( $attachment_path ) && $attachment_id ) {
                $image_size = @getimagesize( $attachment_path );
                if ( $image_size ) {
                    // Include attachment ID in the key to scope tracking per-attachment
                    $dimension_key = $attachment_id . '_' . $image_size[0] . 'x' . $image_size[1];
                    // Check if we already have a file with these dimensions FOR THIS ATTACHMENT
                    if ( isset( self::$generatedDimensions[$dimension_key] ) ) {
                        // We already uploaded a file with these dimensions
                        // Check if we should keep local files
                        $settings = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, array() );
                        $keep_local = ( isset( $settings[UPCASTED_KEEP_LOCAL_FILES] ) ? $settings[UPCASTED_KEEP_LOCAL_FILES] : 'yes' );
                        // Only delete the duplicate if we're not keeping local files
                        if ( $keep_local === 'no' ) {
                            wp_delete_file( $attachment_path );
                        }
                        // Return the S3 URL of the existing file
                        $existing_file = self::$generatedDimensions[$dimension_key];
                        return "{$this->cloudManipulatorBasePath}/" . $existing_file;
                    }
                    // Track this as the first file with these dimensions for this attachment
                    self::$generatedDimensions[$dimension_key] = $original_relative_path;
                }
            }
            // Check if we already uploaded this exact file during current attachment processing
            // This prevents duplicates when multiple sizes have same dimensions (e.g., large & medium_large both 768x1024)
            if ( isset( self::$uploadedThisAttachment[$original_relative_path] ) ) {
                return "{$this->cloudManipulatorBasePath}/" . self::$uploadedThisAttachment[$original_relative_path];
            }
            // Upload to S3 - if path was adjusted by pending rename, use that adjusted path directly
            if ( $applied_rename ) {
                // Path was already adjusted, upload directly to that S3 path
                $bucket = get_option( UPCASTED_S3_OFFLOAD_SETTINGS )[UPCASTED_S3_OFFLOAD_BUCKET];
                $final_s3_path = $this->get_available_s3_path( $bucket, $original_relative_path );
                $this->cloudManipulator->upload( $bucket, $final_s3_path, $attachment_path );
                $actual_s3_path = $final_s3_path;
            } else {
                // Normal upload with duplicate detection
                $actual_s3_path = $this->upcasted_upload_s3( $attachment_path );
            }
            // Track that we've uploaded this file
            self::$uploadedThisAttachment[$original_relative_path] = $actual_s3_path;
            // Don't delete local files here - WordPress and the migration tool handle cleanup
            // If file was renamed during upload, update the path WordPress will use
            // WordPress expects the return value to determine what filename to save in metadata
            // We need to return a path that reflects the actual S3 filename, not the original
            if ( $actual_s3_path && $actual_s3_path !== $original_relative_path ) {
                $actual_basename = basename( $actual_s3_path );
                // Return the local path with the NEW basename so WordPress saves the correct filename
                // WordPress will extract just the filename from this path for metadata
                $local_dir = dirname( $attachment_path );
                $renamed_local_path = $local_dir . '/' . $actual_basename;
                // Also store mapping for update_attachment_metadata as backup
                if ( $attachment_id ) {
                    $original_basename = basename( $original_relative_path );
                    if ( !isset( self::$thumbnailRenameMapping[$attachment_id] ) ) {
                        self::$thumbnailRenameMapping[$attachment_id] = array();
                    }
                    self::$thumbnailRenameMapping[$attachment_id][$original_basename] = $actual_basename;
                }
                return $renamed_local_path;
            }
            return $attachment_path;
        }
        return $attachment_path;
    }

    // ============================================================
    // SECTION 5: FILE OPERATIONS & URL MANIPULATION
    // ============================================================
    /**
     * Upload file to S3 with duplicate detection and renaming
     * 
     * Checks if file exists on S3 and finds available name with increment if needed.
     * Does not rename local file - metadata is updated to reflect S3 filename.
     * 
     * @since 3.0.0
     * @param string $attachment_path Full local file path
     * @return string|null S3 relative path or null on failure
     */
    private function upcasted_upload_s3( string $attachment_path ) {
        $relative_path = $this->upcasted_get_uploaded_image_path( $attachment_path );
        if ( !empty( get_option( UPCASTED_S3_OFFLOAD_SETTINGS ) ) && isset( get_option( UPCASTED_S3_OFFLOAD_SETTINGS )[UPCASTED_S3_OFFLOAD_BUCKET] ) && null !== $relative_path ) {
            $bucket = get_option( UPCASTED_S3_OFFLOAD_SETTINGS )[UPCASTED_S3_OFFLOAD_BUCKET];
            // Check if file already exists in S3 and find available name if needed
            $final_s3_path = $this->get_available_s3_path( $bucket, $relative_path );
            // Upload to S3 with the final path (which may be different from relative_path if duplicate)
            // Note: We do NOT rename the local file - let WordPress keep its local filename
            // The metadata update logic will handle updating the database to reflect the S3 filename
            $this->cloudManipulator->upload( $bucket, $final_s3_path, $attachment_path );
            return $final_s3_path;
        }
        return null;
    }

    /**
     * Convert full local path to relative S3 path
     * 
     * Wrapper for multisite-compatible path conversion.
     * 
     * @since 3.0.0
     * @param string $full_path Full local file path
     * @return string|null Relative S3 path
     */
    private function upcasted_get_uploaded_image_path( $full_path ) {
        // Use dynamic method for multisite compatibility
        return $this->get_site_relative_path( $full_path );
    }

    /**
     * Find available S3 path with auto-incrementing for duplicates
     * 
     * If file exists on S3, appends increment number to filename (e.g., file-1.jpg, file-2.jpg).
     * Handles both regular files and thumbnails with dimensions (e.g., image-150x150.jpg).
     * 
     * @since 3.0.0
     * @param string $bucket        S3 bucket name
     * @param string $relative_path Desired relative S3 path
     * @return string Available S3 path (may be incremented)
     */
    private function get_available_s3_path( string $bucket, string $relative_path ) : string {
        // If file doesn't exist, return original path
        if ( false === $this->cloudManipulator->check_object( $bucket, $relative_path ) ) {
            return $relative_path;
        }
        // File exists, find available name with increment
        $pathInfo = pathinfo( $relative_path );
        $directory = $pathInfo['dirname'];
        $filename = $pathInfo['filename'];
        $extension = ( isset( $pathInfo['extension'] ) ? '.' . $pathInfo['extension'] : '' );
        // Handle root directory case
        if ( $directory === '.' ) {
            $directory = '';
        } else {
            $directory .= '/';
        }
        // Check if this is a thumbnail (contains dimensions like -240x300)
        if ( preg_match( '/^(.+)-(\\d+x\\d+)$/', $filename, $matches ) ) {
            // This is a thumbnail: filename-123x456
            $base_filename = $matches[1];
            $dimensions = $matches[2];
            // Check if the base filename already has an increment from sanitize_file_name
            if ( preg_match( '/^(.+)-(\\d+)$/', $base_filename, $base_matches ) ) {
                // Base filename already incremented (e.g., "7-scaled-1")
                $original_base = $base_matches[1];
                $existing_increment = (int) $base_matches[2];
                $increment = $existing_increment + 1;
            } else {
                // Base filename not incremented
                $increment = 1;
            }
            do {
                if ( isset( $original_base ) ) {
                    $new_relative_path = $directory . $original_base . '-' . $increment . '-' . $dimensions . $extension;
                } else {
                    $new_relative_path = $directory . $base_filename . '-' . $increment . '-' . $dimensions . $extension;
                }
                $increment++;
            } while ( false !== $this->cloudManipulator->check_object( $bucket, $new_relative_path ) );
        } else {
            // Regular file (not a thumbnail)
            // Check for WordPress-generated suffixes that should be preserved after the increment
            $wordpress_suffix = '';
            $base_for_increment = $filename;
            if ( preg_match( '/^(.+)-(scaled|rotated|edited)$/', $filename, $suffix_matches ) ) {
                // Has WordPress suffix like -scaled, -rotated, -edited
                $base_for_increment = $suffix_matches[1];
                $wordpress_suffix = '-' . $suffix_matches[2];
            }
            // Check if the base (without WP suffix) already has an increment
            if ( preg_match( '/^(.+)-(\\d+)$/', $base_for_increment, $matches ) ) {
                // Already incremented (e.g., "IMG_8971-1")
                $original_filename = $matches[1];
                $existing_increment = (int) $matches[2];
                $increment = $existing_increment + 1;
            } else {
                // Not incremented yet
                $increment = 1;
            }
            do {
                if ( isset( $original_filename ) ) {
                    // Continue from existing increment: IMG_8971-2-scaled.jpg
                    $new_relative_path = $directory . $original_filename . '-' . $increment . $wordpress_suffix . $extension;
                } else {
                    // First increment: IMG_8971-1-scaled.jpg
                    $new_relative_path = $directory . $base_for_increment . '-' . $increment . $wordpress_suffix . $extension;
                }
                $increment++;
            } while ( false !== $this->cloudManipulator->check_object( $bucket, $new_relative_path ) );
        }
        return $new_relative_path;
    }

    /**
     * Temporarily download S3 file to local for image editor
     * 
     * WordPress filter that downloads file from S3 when needed for image editing.
     * Schedules temporary file for cleanup after use.
     * 
     * @since 3.0.0
     * @param string $file          Current file path
     * @param int    $attachment_id Attachment post ID
     * @return string Local file path
     */
    public function upcasted_get_attached_file( $file, $attachment_id ) : string {
        $attachment_details = wp_get_attachment_metadata( $attachment_id );
        if ( is_array( $attachment_details ) && array_key_exists( 'bucket', $attachment_details ) && array_key_exists( 'file', $attachment_details ) ) {
            $action = ( filter_input( INPUT_GET, 'action' ) ?: filter_input( INPUT_POST, 'action' ) );
            // For local path: get_current_upload_dir() already includes sites/X/ for multisite
            // So we need to strip sites/X/ from file path if present to avoid duplication
            $file_path = $attachment_details['file'];
            if ( is_multisite() && strpos( $file_path, 'sites/' ) === 0 ) {
                $blog_id = get_current_blog_id();
                $file_path = str_replace( "sites/{$blog_id}/", '', $file_path );
            }
            $newPath = $this->get_current_upload_dir() . '/' . $file_path;
            if ( in_array( $action, array('image-editor', 'imgedit-preview') ) ) {
                // input var okay
                foreach ( debug_backtrace() as $caller ) {
                    if ( isset( $caller['function'] ) && '_load_image_to_edit_path' == $caller['function'] ) {
                        // For S3: ensure path includes sites/X/ prefix
                        $s3Path = $attachment_details['file'];
                        if ( is_multisite() && strpos( $s3Path, 'sites/' ) !== 0 ) {
                            $blog_id = get_current_blog_id();
                            $s3Path = "sites/{$blog_id}/{$s3Path}";
                        }
                        try {
                            $object = $this->cloudManipulator->get_object_from_bucket( $this->bucket, $s3Path );
                            if ( file_put_contents( $newPath, $object['Body']->getContents() ) ) {
                                $this->tempFilesToCleanup[] = $newPath;
                                return $newPath;
                            }
                        } catch ( Exception $e ) {
                            error_log( 'Upcasted S3 Offload - S3 download for editing failed for attachment ' . $attachment_id . ': ' . $e->getMessage() );
                        }
                    }
                }
            }
        }
        return $file;
    }

    /**
     * Update attachment metadata and apply thumbnail rename mappings
     * 
     * WordPress filter that cleans up temporary files and updates metadata
     * with correct S3 filenames when duplicates were renamed during upload.
     * 
     * @since 3.0.0
     * @param array $data    Attachment metadata
     * @param int   $post_id Attachment post ID
     * @return array Modified metadata
     */
    public function upcasted_update_attachment_metadata( $data, $post_id ) {
        foreach ( $this->tempFilesToCleanup as $key => $path ) {
            if ( !empty( $path ) ) {
                wp_delete_file( $path );
            } else {
                unset($this->tempFilesToCleanup[$key]);
            }
        }
        // If we have thumbnail rename mappings for this specific attachment, update the metadata
        if ( isset( self::$thumbnailRenameMapping[$post_id] ) && isset( $data['sizes'] ) && is_array( $data['sizes'] ) ) {
            $mappings = self::$thumbnailRenameMapping[$post_id];
            // Also check if the main file (scaled version) was renamed
            if ( isset( $data['file'] ) ) {
                $main_basename = basename( $data['file'] );
                if ( isset( $mappings[$main_basename] ) ) {
                    $file_dir = dirname( $data['file'] );
                    $data['file'] = $file_dir . '/' . $mappings[$main_basename];
                    // For multisite, strip sites/ prefix before saving to post meta
                    $meta_file_path = $data['file'];
                    if ( is_multisite() && strpos( $meta_file_path, 'sites/' ) === 0 ) {
                        $blog_id = get_current_blog_id();
                        $site_prefix = "sites/{$blog_id}/";
                        if ( strpos( $meta_file_path, $site_prefix ) === 0 ) {
                            $meta_file_path = substr( $meta_file_path, strlen( $site_prefix ) );
                        }
                    }
                    update_post_meta( $post_id, '_wp_attached_file', $meta_file_path );
                }
            }
            foreach ( $data['sizes'] as $size => &$size_data ) {
                if ( isset( $size_data['file'] ) ) {
                    $current_filename = $size_data['file'];
                    // Try exact match first
                    if ( isset( $mappings[$current_filename] ) ) {
                        $size_data['file'] = $mappings[$current_filename];
                    } else {
                        // Try pattern matching - look for the base filename change
                        foreach ( $mappings as $old_name => $new_name ) {
                            // Extract the base filename (without dimensions like -150x150)
                            $old_filename_noext = pathinfo( $old_name, PATHINFO_FILENAME );
                            $new_filename_noext = pathinfo( $new_name, PATHINFO_FILENAME );
                            $current_filename_noext = pathinfo( $current_filename, PATHINFO_FILENAME );
                            // If current filename starts with the old base filename, replace it
                            if ( strpos( $current_filename_noext, $old_filename_noext ) === 0 ) {
                                $extension = pathinfo( $current_filename, PATHINFO_EXTENSION );
                                $suffix = substr( $current_filename_noext, strlen( $old_filename_noext ) );
                                $size_data['file'] = $new_filename_noext . $suffix . '.' . $extension;
                                break;
                            }
                        }
                    }
                }
            }
            // Don't clear mappings here - they're needed by upcasted_generate_attachment_metadata
            // which may run after this hook at the same priority (1500)
        }
        // DON'T clear tracking here - this is called after EACH thumbnail, not after ALL thumbnails
        // The tracking array is cleared in upcasted_generate_attachment_metadata after all thumbnails are done
        return $data;
    }

    /**
     * Replace local attachment URL with S3 URL
     * 
     * WordPress filter that returns S3 URL for attachments stored in cloud.
     * Handles multisite path structure correctly.
     * 
     * @since 3.0.0
     * @param string $path          Original attachment URL
     * @param int    $attachment_id Attachment post ID
     * @return string S3 URL or original path
     */
    public function upcasted_get_attachment_url( $path, $attachment_id ) : string {
        $metadata = wp_get_attachment_metadata( $attachment_id );
        if ( is_array( $metadata ) ) {
            if ( array_key_exists( 'bucket', $metadata ) && array_key_exists( 'file', $metadata ) ) {
                // For multisite, we need to reconstruct the full path including sites/X/
                if ( is_multisite() ) {
                    $full_file_path = get_attached_file( $attachment_id, true );
                    $wp_upload_root = WP_CONTENT_DIR . '/uploads';
                    if ( strpos( $full_file_path, $wp_upload_root . '/' ) === 0 ) {
                        $relative_path = substr( $full_file_path, strlen( $wp_upload_root . '/' ) );
                        $s3_url = "{$this->cloudManipulatorBasePath}/{$relative_path}";
                        return $s3_url;
                    }
                }
                // Single site or fallback
                $s3_url = "{$this->cloudManipulatorBasePath}/{$metadata['file']}";
                return $s3_url;
            } else {
                if ( array_key_exists( 'bucket', $metadata ) ) {
                    // Get the full file path
                    $full_file_path = get_attached_file( $attachment_id, true );
                    // For multisite, preserve the sites/X/ structure in the S3 path
                    if ( is_multisite() ) {
                        $wp_upload_root = WP_CONTENT_DIR . '/uploads';
                        if ( strpos( $full_file_path, $wp_upload_root . '/' ) === 0 ) {
                            $relative_path = substr( $full_file_path, strlen( $wp_upload_root . '/' ) );
                            $s3_url = "{$this->cloudManipulatorBasePath}/{$relative_path}";
                            return $s3_url;
                        }
                    }
                    // Fallback for single site
                    $fallback_url = "{$this->cloudManipulatorBasePath}" . str_replace( $this->get_current_upload_dir(), '', $full_file_path );
                    return $fallback_url;
                }
            }
        }
        return $path;
    }

    /**
     * Check if attachment MIME type is allowed for S3 upload (premium feature)
     * 
     * In free version, all MIME types are allowed. In premium, checks against
     * configured allowed file types.
     * 
     * @since 3.0.0
     * @param int|string $attachment_id_or_path Attachment ID or file path
     * @return bool True if MIME type allowed, false otherwise
     */
    public function upcasted_check_mime_type( $attachment_id_or_path ) {
        return true;
    }

    /**
     * Capture attachment ID as soon as attachment is created
     * 
     * WordPress action called when attachment post is inserted
     * 
     * @since 3.1.0
     * @param int $attachment_id Attachment post ID
     */
    public function upcasted_capture_attachment_id_early( $attachment_id ) {
        self::$currentAttachmentId = $attachment_id;
    }

    /**
     * Capture attachment ID early before thumbnails are generated
     * 
     * WordPress filter called at priority 1 (before thumbnail generation)
     * to capture the attachment ID so thumbnails can use it.
     * 
     * @since 3.1.0
     * @param array      $metadata      Attachment metadata
     * @param int        $attachment_id Attachment post ID
     * @param string|null $context      Generation context
     * @return array Unmodified metadata
     */
    public function upcasted_capture_attachment_id( $metadata, $attachment_id, $context = null ) {
        // Set the attachment ID so thumbnails can use it
        self::$currentAttachmentId = $attachment_id;
        return $metadata;
    }

    /**
     * Upload attachment to S3 during initial processing
     * 
     * WordPress filter that uploads main file to S3 when attachment metadata is generated.
     * Respects auto-upload setting. Handles file renames and metadata updates.
     * 
     * @since 3.0.0
     * @param array      $metadata      Attachment metadata
     * @param int        $attachment_id Attachment post ID
     * @param string|null $context      Generation context
     * @return array Modified metadata with bucket information
     */
    public function upcasted_generate_attachment_metadata( $metadata, $attachment_id, $context = null ) {
        // Check if auto-upload to S3 is enabled
        $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, array() );
        $auto_upload_enabled = ( isset( $options[UPCASTED_AUTO_UPLOAD_TO_S3] ) ? $options[UPCASTED_AUTO_UPLOAD_TO_S3] : 'yes' );
        // Default to yes for backward compatibility
        // If auto-upload is disabled, return metadata without uploading to S3
        if ( $auto_upload_enabled === 'no' ) {
            return $metadata;
        }
        if ( $this->upcasted_check_mime_type( $attachment_id ) ) {
            // Check if this is a HEIC/HEIF file with incomplete metadata
            // WordPress doesn't generate thumbnails for HEIC in older versions (6.7.x)
            // Newer versions (6.8+) auto-convert HEIC to JPG
            $mime_type = get_post_mime_type( $attachment_id );
            $is_heic = in_array( $mime_type, ['image/heic', 'image/heif'] );
            if ( empty( $metadata['width'] ) || empty( $metadata['height'] ) || empty( $metadata['file'] ) ) {
                // For HEIC files, upload the original file even without complete metadata
                if ( $is_heic ) {
                    $path = get_attached_file( $attachment_id );
                    if ( $path && file_exists( $path ) ) {
                        $original_relative_path = $this->upcasted_get_uploaded_image_path( $path );
                        $actual_s3_path = $this->upcasted_upload_s3( $path );
                        if ( $actual_s3_path ) {
                            $metadata['bucket'] = $this->bucket;
                            $metadata['file'] = $actual_s3_path;
                        }
                    }
                    return $metadata;
                }
                // For other file types with incomplete metadata, skip processing
                return $metadata;
            }
            $metadata['bucket'] = $this->bucket;
            // Set current attachment ID so thumbnail uploads can associate with it
            self::$currentAttachmentId = $attachment_id;
            if ( isset( $metadata['original_image'] ) ) {
                $path = wp_get_original_image_path( $attachment_id );
            } else {
                $path = get_attached_file( $attachment_id );
            }
            // Get the actual filename from disk - this is what WordPress saved after sanitize_file_name
            $actual_disk_basename = basename( $path );
            $metadata_basename = ( isset( $metadata['file'] ) ? basename( $metadata['file'] ) : '' );
            // If the filename on disk differs from metadata (e.g., HEIC conversion renamed it), update metadata
            if ( $actual_disk_basename !== $metadata_basename && !empty( $metadata_basename ) ) {
                $metadata_noext = pathinfo( $metadata_basename, PATHINFO_FILENAME );
                $actual_noext = pathinfo( $actual_disk_basename, PATHINFO_FILENAME );
                // Create rename map for thumbnails to use
                self::$mainFileRenameMap[$attachment_id] = [
                    'old' => $metadata_noext,
                    'new' => $actual_noext,
                ];
            }
            $original_relative_path = $this->upcasted_get_uploaded_image_path( $path );
            $actual_s3_path = $this->upcasted_upload_s3( $path );
            // Track main file rename for thumbnail adjustment (if S3 renamed it further)
            if ( $actual_s3_path && $actual_s3_path !== $original_relative_path ) {
                $original_basename = basename( $original_relative_path, '.' . pathinfo( $original_relative_path, PATHINFO_EXTENSION ) );
                $new_basename = basename( $actual_s3_path, '.' . pathinfo( $actual_s3_path, PATHINFO_EXTENSION ) );
                self::$mainFileRenameMap[$attachment_id] = [
                    'old' => $original_basename,
                    'new' => $new_basename,
                ];
                // If we're uploading the original image (not the scaled version),
                // don't update metadata['file'] yet - it will be updated later when scaled version is uploaded
                if ( !isset( $metadata['original_image'] ) ) {
                    // No scaled version - this is the main file
                    $metadata['file'] = $actual_s3_path;
                    // For multisite, strip sites/ prefix before saving to post meta
                    $meta_file_path = $actual_s3_path;
                    if ( is_multisite() && strpos( $meta_file_path, 'sites/' ) === 0 ) {
                        $blog_id = get_current_blog_id();
                        $site_prefix = "sites/{$blog_id}/";
                        if ( strpos( $meta_file_path, $site_prefix ) === 0 ) {
                            $meta_file_path = substr( $meta_file_path, strlen( $site_prefix ) );
                        }
                    }
                    update_post_meta( $attachment_id, '_wp_attached_file', $meta_file_path );
                }
            }
            // If the file was renamed during upload, update metadata to reflect the new filename
            if ( $actual_s3_path && $actual_s3_path !== $original_relative_path && isset( $metadata['file'] ) ) {
                // WordPress stores _wp_attached_file relative to the current site's uploads base.
                // On multisite, strip leading sites/{blog_id}/ from the S3 path before saving to metadata.
                $meta_file_path = $actual_s3_path;
                if ( is_multisite() && strpos( $meta_file_path, 'sites/' ) === 0 ) {
                    $blog_id = get_current_blog_id();
                    $prefix = "sites/{$blog_id}/";
                    if ( strpos( $meta_file_path, $prefix ) === 0 ) {
                        $meta_file_path = substr( $meta_file_path, strlen( $prefix ) );
                    }
                }
                $metadata['file'] = $meta_file_path;
                // Update the _wp_attached_file post meta directly
                // This is critical for WordPress to generate the correct URL
                update_post_meta( $attachment_id, '_wp_attached_file', $meta_file_path );
                // Update thumbnail filenames in metadata to match the renamed main file
                // If main file was renamed from "3.png" to "3-1.png", thumbnails on disk
                // are "3-1-300x300.png" but metadata still says "3-300x300.png"
                if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
                    $original_basename = basename( $original_relative_path, '.' . pathinfo( $original_relative_path, PATHINFO_EXTENSION ) );
                    $new_basename = basename( $actual_s3_path, '.' . pathinfo( $actual_s3_path, PATHINFO_EXTENSION ) );
                    foreach ( $metadata['sizes'] as $size_name => &$size_data ) {
                        if ( isset( $size_data['file'] ) ) {
                            // Replace old basename with new basename in thumbnail filename
                            $old_thumb_file = $size_data['file'];
                            $new_thumb_file = str_replace( $original_basename . '-', $new_basename . '-', $old_thumb_file );
                            $size_data['file'] = $new_thumb_file;
                        }
                    }
                    unset($size_data);
                    // Break reference
                }
            }
            // Clear pending renames after upload (no longer used for metadata updates)
            if ( !empty( self::$pendingRenames ) ) {
                self::$pendingRenames = [];
            }
            // If we have a rename map (disk filename != metadata filename), update metadata to match disk
            if ( isset( self::$mainFileRenameMap[$attachment_id] ) ) {
                $rename_map = self::$mainFileRenameMap[$attachment_id];
                // Update main file path
                if ( isset( $metadata['file'] ) ) {
                    $current_file = $metadata['file'];
                    $file_dir = dirname( $current_file );
                    $file_ext = pathinfo( $current_file, PATHINFO_EXTENSION );
                    $file_basename = pathinfo( $current_file, PATHINFO_FILENAME );
                    // Replace old basename with new in the main file path
                    if ( strpos( $file_basename, $rename_map['old'] ) === 0 ) {
                        $new_file_basename = str_replace( $rename_map['old'], $rename_map['new'], $file_basename );
                        $metadata['file'] = $file_dir . '/' . $new_file_basename . '.' . $file_ext;
                        // For multisite, strip sites/ prefix before saving to post meta
                        $meta_file_path = $metadata['file'];
                        if ( is_multisite() && strpos( $meta_file_path, 'sites/' ) === 0 ) {
                            $blog_id = get_current_blog_id();
                            $site_prefix = "sites/{$blog_id}/";
                            if ( strpos( $meta_file_path, $site_prefix ) === 0 ) {
                                $meta_file_path = substr( $meta_file_path, strlen( $site_prefix ) );
                            }
                        }
                        update_post_meta( $attachment_id, '_wp_attached_file', $meta_file_path );
                    }
                }
                // Update thumbnail filenames
                if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
                    foreach ( $metadata['sizes'] as $size_name => &$size_data ) {
                        if ( isset( $size_data['file'] ) ) {
                            $old_thumb_file = $size_data['file'];
                            $new_thumb_file = str_replace( $rename_map['old'] . '-', $rename_map['new'] . '-', $old_thumb_file );
                            if ( $new_thumb_file !== $old_thumb_file ) {
                                $size_data['file'] = $new_thumb_file;
                            }
                        }
                    }
                    unset($size_data);
                    // Break reference
                }
            }
            // Update original_image metadata and main file path if the original file was renamed
            if ( isset( $metadata['original_image'] ) ) {
                $original_basename = pathinfo( $metadata['original_image'], PATHINFO_FILENAME );
                $original_ext = pathinfo( $metadata['original_image'], PATHINFO_EXTENSION );
                // Check if we have a rename map for this attachment
                if ( isset( self::$mainFileRenameMap[$attachment_id] ) ) {
                    $rename_map = self::$mainFileRenameMap[$attachment_id];
                    // If original_image filename starts with old basename, update it
                    if ( strpos( $original_basename, $rename_map['old'] ) === 0 ) {
                        $new_original_basename = str_replace( $rename_map['old'], $rename_map['new'], $original_basename );
                        $metadata['original_image'] = $new_original_basename . '.' . $original_ext;
                    }
                    // WordPress may have changed metadata['file'] to match the renamed original file
                    // but we need it to point to the scaled version
                    // Example: WordPress sets it to "AvocadoHass_Main-2.webp" but should be "AvocadoHass_Main-2-scaled.webp"
                    $current_file = $metadata['file'];
                    $current_file_basename = pathinfo( $current_file, PATHINFO_FILENAME );
                    $current_file_ext = pathinfo( $current_file, PATHINFO_EXTENSION );
                    $file_dir = pathinfo( $current_file, PATHINFO_DIRNAME );
                    // If current file matches the renamed original (without -scaled), add -scaled suffix
                    if ( $current_file_basename === $rename_map['new'] && strpos( $current_file_basename, '-scaled' ) === false ) {
                        // WordPress removed the -scaled suffix, add it back
                        $metadata['file'] = $file_dir . '/' . $rename_map['new'] . '-scaled.' . $current_file_ext;
                        update_post_meta( $attachment_id, '_wp_attached_file', $metadata['file'] );
                    } elseif ( strpos( $current_file_basename, $rename_map['old'] ) === 0 ) {
                        // File still has old basename, update to new basename
                        $new_file_basename = str_replace( $rename_map['old'], $rename_map['new'], $current_file_basename );
                        $metadata['file'] = $file_dir . '/' . $new_file_basename . '.' . $current_file_ext;
                        update_post_meta( $attachment_id, '_wp_attached_file', $metadata['file'] );
                    }
                }
            }
            // Check if the scaled file (metadata['file']) was renamed during thumbnail upload
            // The scaled file is uploaded via image_make_intermediate_size, so check thumbnailRenameMapping
            if ( isset( $metadata['file'] ) ) {
                $current_file_basename = basename( $metadata['file'] );
                if ( isset( self::$thumbnailRenameMapping[$attachment_id] ) ) {
                    if ( isset( self::$thumbnailRenameMapping[$attachment_id][$current_file_basename] ) ) {
                        $new_basename = self::$thumbnailRenameMapping[$attachment_id][$current_file_basename];
                        $file_dir = dirname( $metadata['file'] );
                        $metadata['file'] = $file_dir . '/' . $new_basename;
                        // For multisite, strip sites/ prefix before saving to post meta
                        $meta_file_path = $metadata['file'];
                        if ( is_multisite() && strpos( $meta_file_path, 'sites/' ) === 0 ) {
                            $blog_id = get_current_blog_id();
                            $site_prefix = "sites/{$blog_id}/";
                            if ( strpos( $meta_file_path, $site_prefix ) === 0 ) {
                                $meta_file_path = substr( $meta_file_path, strlen( $site_prefix ) );
                            }
                        }
                        update_post_meta( $attachment_id, '_wp_attached_file', $meta_file_path );
                    }
                }
            }
            // Delete local files after successful upload if configured
            // Re-check the option to get the current value (don't rely on constructor flag)
            $options = get_option( UPCASTED_S3_OFFLOAD_SETTINGS, array() );
            $keep_local = ( isset( $options[UPCASTED_KEEP_LOCAL_FILES] ) ? $options[UPCASTED_KEEP_LOCAL_FILES] : 'yes' );
            $should_delete_local = $keep_local === 'no';
            if ( $should_delete_local ) {
                // Delete main/scaled file
                if ( $path && file_exists( $path ) ) {
                    wp_delete_file( $path );
                }
                // Delete original image if exists
                if ( isset( $metadata['original_image'] ) ) {
                    $original_path = wp_get_original_image_path( $attachment_id );
                    if ( $original_path && file_exists( $original_path ) ) {
                        wp_delete_file( $original_path );
                    }
                }
                // Delete all thumbnails
                if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
                    $upload_dir = wp_upload_dir();
                    $fileDir = pathinfo( $metadata['file'], PATHINFO_DIRNAME );
                    foreach ( $metadata['sizes'] as $size_data ) {
                        if ( isset( $size_data['file'] ) ) {
                            $thumb_path = $upload_dir['basedir'] . '/' . $fileDir . '/' . $size_data['file'];
                            if ( file_exists( $thumb_path ) ) {
                                wp_delete_file( $thumb_path );
                            }
                        }
                    }
                }
            }
            // Clear tracking arrays now that all thumbnails are processed
            error_log( "Upcasted S3: Clearing tracking arrays for attachment_id: {$attachment_id}" );
            self::$currentAttachmentId = null;
            self::$uploadedThisAttachment = [];
            self::$generatedDimensions = [];
            if ( isset( self::$mainFileRenameMap[$attachment_id] ) ) {
                unset(self::$mainFileRenameMap[$attachment_id]);
            }
            // Clear the rename mappings for this attachment now that metadata is updated
            if ( isset( self::$thumbnailRenameMapping[$attachment_id] ) ) {
                unset(self::$thumbnailRenameMapping[$attachment_id]);
            }
        }
        return $metadata;
    }

    /**
     * Generate incremented filename for duplicates on S3
     * 
     * WordPress filter that checks S3 for existing files and appends increment if needed.
     * 
     * @since 3.0.0
     * @param string $file_name Original filename
     * @return string Available filename (possibly incremented)
     */
    public function upcasted_append_duplicate_name( $file_name ) : string {
        if ( !empty( $file_name ) ) {
            $upload_dir = wp_upload_dir();
            $info = pathinfo( $file_name );
            $extension = ( isset( $info['extension'] ) ? '.' . $info['extension'] : '' );
            // Build S3 path with multisite prefix if needed
            $relative_path = ltrim( $upload_dir['subdir'], '/' ) . '/';
            if ( is_multisite() ) {
                $blog_id = get_current_blog_id();
                if ( $blog_id > 1 ) {
                    // For secondary sites, add sites/X/ prefix
                    $relative_path = "sites/{$blog_id}/" . $relative_path;
                }
            }
            if ( false === $this->cloudManipulator->check_object( $this->bucket, $relative_path . $file_name ) ) {
                return $file_name;
            }
            $increment = 1;
            while ( false !== $this->cloudManipulator->check_object( $this->bucket, $relative_path . $this->upcasted_get_new_file_name( $file_name, $extension, $increment ) ) ) {
                ++$increment;
            }
            $new_file_name = $this->upcasted_get_new_file_name( $file_name, $extension, $increment );
            // Store pending rename so thumbnails can use it
            $old_basename = pathinfo( $file_name, PATHINFO_FILENAME );
            $new_basename = pathinfo( $new_file_name, PATHINFO_FILENAME );
            self::$pendingRenames[$old_basename] = $new_basename;
            return $new_file_name;
        }
        return '';
    }

    /**
     * Delete attachment files from S3 when attachment is deleted
     * 
     * WordPress action that removes main file and all thumbnails from S3
     * when attachment is permanently deleted.
     * 
     * @since 3.0.0
     * @param int $post_id Attachment post ID being deleted
     */
    public function upcasted_delete_attachment( $post_id ) {
        $deleted = [];
        $blog_id = ( is_multisite() ? get_current_blog_id() : 0 );
        //
        // 1. Delete FULL SIZE via URL (always the safest - works for WP All Import)
        //
        $full = wp_get_attachment_image_src( $post_id, 'full' );
        if ( $full && isset( $full[0] ) ) {
            $url = $full[0];
            if ( strpos( $url, $this->cloudManipulatorBasePath . '/' ) !== false ) {
                $s3_key = explode( $this->cloudManipulatorBasePath . '/', $url )[1];
                $this->cloudManipulator->delete( $this->bucket, $s3_key );
                $deleted[$s3_key] = true;
            }
        }
        //
        // 2. Get metadata (sizes + file)
        //
        $meta = wp_get_attachment_metadata( $post_id );
        if ( !is_array( $meta ) ) {
            return;
            // No metadata - URL deletion above is all we can do
        }
        // Extract base directory (e.g., "2025/11/") and add multisite prefix if needed
        $base_dir = '';
        if ( !empty( $meta['file'] ) ) {
            $base_dir = dirname( $meta['file'] );
            if ( $base_dir === '.' ) {
                $base_dir = '';
            } else {
                // Add multisite prefix if needed
                if ( is_multisite() && $blog_id > 1 && strpos( $base_dir, 'sites/' ) !== 0 ) {
                    $base_dir = "sites/{$blog_id}/" . $base_dir;
                }
                $base_dir .= '/';
            }
        }
        //
        // 3. Delete generated thumbnail sizes from metadata
        //
        if ( !empty( $meta['sizes'] ) && is_array( $meta['sizes'] ) ) {
            foreach ( $meta['sizes'] as $size_key => $size ) {
                if ( !empty( $size['file'] ) ) {
                    $s3_key = $base_dir . $size['file'];
                    if ( empty( $deleted[$s3_key] ) ) {
                        $this->cloudManipulator->delete( $this->bucket, $s3_key );
                        $deleted[$s3_key] = true;
                    }
                }
            }
        }
        //
        // 4. Delete _wp_attachment_backup_sizes entries (edited images, old versions)
        //
        $backup = get_post_meta( $post_id, '_wp_attachment_backup_sizes', true );
        if ( !empty( $backup ) && is_array( $backup ) ) {
            foreach ( $backup as $backup_key => $backup_item ) {
                if ( !empty( $backup_item['file'] ) ) {
                    $s3_key = $base_dir . $backup_item['file'];
                    if ( empty( $deleted[$s3_key] ) ) {
                        $this->cloudManipulator->delete( $this->bucket, $s3_key );
                        $deleted[$s3_key] = true;
                    }
                }
            }
        }
        //
        // 5. Delete original_image, if present
        //
        if ( !empty( $meta['original_image'] ) ) {
            $s3_key = $base_dir . $meta['original_image'];
            if ( empty( $deleted[$s3_key] ) ) {
                $this->cloudManipulator->delete( $this->bucket, $s3_key );
                $deleted[$s3_key] = true;
            }
        }
        //
        // 6. Delete main file from metadata['file']
        //
        if ( !empty( $meta['file'] ) ) {
            $s3_key = $meta['file'];
            // Add multisite prefix if needed
            if ( is_multisite() && $blog_id > 1 && strpos( $s3_key, 'sites/' ) !== 0 ) {
                $s3_key = "sites/{$blog_id}/" . $s3_key;
            }
            if ( empty( $deleted[$s3_key] ) ) {
                $this->cloudManipulator->delete( $this->bucket, $s3_key );
                $deleted[$s3_key] = true;
            }
        }
    }

    /**
     * Generate filename with increment suffix
     * 
     * Helper function that appends increment number to filename.
     * 
     * @since 3.0.0
     * @param string $fileName  Original filename
     * @param string $extension File extension (with dot)
     * @param int    $increment Increment number to append
     * @return string Filename with increment
     */
    private function upcasted_get_new_file_name( $fileName, $extension, $increment ) {
        return basename( $fileName, $extension ) . '-' . $increment . $extension;
    }

    /**
     * Create new S3 bucket
     * 
     * @since 3.0.0
     * @param string $bucketName Name for new S3 bucket
     * @return mixed Result from cloud manipulator
     */
    public function upcasted_create_bucket( string $bucketName ) {
        return $this->cloudManipulator->create_bucket( $bucketName );
    }

    /**
     * Get count of attachments matching migration criteria
     * 
     * Memory-optimized: Uses found_posts instead of loading all attachments
     * 
     * @since 3.0.0
     * @param string $metaCompare Meta comparison type (local_to_cloud or cloud_to_local)
     * @return int Number of attachments
     */
    public function upcasted_get_number_of_files( string $metaCompare ) : int {
        // Use posts_per_page=1 and found_posts to avoid loading all attachments into memory
        $query = CloudRepository::getAttachments( $metaCompare, 1 );
        return $query->found_posts;
    }

    /**
     * Reset WordPress hooks after configuration changes
     * 
     * @since 3.0.0
     */
    private function resetHooks() {
        Upcasted_S3_Offload_Init::getInstance()->define_admin_hooks();
    }

    /**
     * Upload any file to S3 with duplicate detection (public API)
     * 
     * Public method for programmatic file uploads. Checks for duplicates and
     * returns upload result with S3 URL.
     * 
     * @since 3.0.6
     * @param string      $local_file_path  Full path to local file
     * @param string|null $s3_relative_path Desired S3 path (auto-generated if null)
     * @return array|false Array with success, s3_path, s3_url, bucket on success; false on failure
     */
    public function upload_file_to_s3( $local_file_path, $s3_relative_path = null ) {
        if ( !file_exists( $local_file_path ) ) {
            return false;
        }
        // If no S3 path specified, use the local filename in uploads directory structure
        if ( !$s3_relative_path ) {
            $s3_relative_path = date( 'Y/m' ) . '/' . basename( $local_file_path );
        }
        if ( !empty( get_option( UPCASTED_S3_OFFLOAD_SETTINGS ) ) && isset( get_option( UPCASTED_S3_OFFLOAD_SETTINGS )[UPCASTED_S3_OFFLOAD_BUCKET] ) ) {
            $bucket = get_option( UPCASTED_S3_OFFLOAD_SETTINGS )[UPCASTED_S3_OFFLOAD_BUCKET];
            // Check if file already exists in S3 and find available name if needed
            $final_s3_path = $this->get_available_s3_path( $bucket, $s3_relative_path );
            try {
                $this->cloudManipulator->upload( $bucket, $final_s3_path, $local_file_path );
                return [
                    'success'    => true,
                    'local_path' => $local_file_path,
                    's3_path'    => $final_s3_path,
                    's3_url'     => $this->cloudManipulatorBasePath . '/' . $final_s3_path,
                    'bucket'     => $bucket,
                ];
            } catch ( Exception $e ) {
                error_log( 'Upcasted S3 Offload - Thumbnail rename failed for attachment ' . $attachment_id . ': ' . $e->getMessage() );
                return [
                    'success' => false,
                    'error'   => $e->getMessage(),
                ];
            }
        }
        return false;
    }

}
