<?php
/**
 * R2 Handler for Cloudflare R2 storage operations
 * 
 * Uses the S3-compatible API via AWS SDK for PHP.
 * 
 * @package suspended-cloud/staticq
 * @since 2.1.0
 */

if (!defined('ABSPATH')) {
    exit;
}

// Prevent redeclaration if file is included multiple times
if (class_exists('SQMedia_R2_Handler')) {
    return;
}

class SQMedia_R2_Handler {
    
    /**
     * S3 client instance
     */
    private $client;
    
    /**
     * Bucket name
     */
    private $bucket_name;
    
    /**
     * Account ID
     */
    private $account_id;
    
    /**
     * Whether client is initialized
     */
    private $initialized = false;
    
    /**
     * Last error message
     */
    private $last_error = '';
    
    /**
     * File existence cache
     */
    private $existence_cache = [];
    
    /**
     * Constructor
     */
    public function __construct() {
        $this->bucket_name = SQMedia_Settings::get_r2_bucket();
        $this->account_id = SQMedia_Settings::get_r2_account_id();
    }
    
    /**
     * Ensure Composer autoloader is loaded
     */
    private function ensure_autoloader(): bool {
        // Check if the class is already available
        if (class_exists('Aws\S3\S3Client')) {
            return true;
        }
        
        // Try to load the autoloader
        $autoloader = SQMEDIA_PLUGIN_DIR . 'vendor/autoload.php';
        
        if (file_exists($autoloader)) {
            require_once $autoloader;
            return class_exists('Aws\S3\S3Client');
        }
        
        return false;
    }
    
    /**
     * Initialize the R2 client
     */
    private function init(): bool {
        if ($this->initialized) {
            return true;
        }
        
        // Ensure autoloader is loaded first
        if (!$this->ensure_autoloader()) {
            $this->last_error = __('AWS SDK not found. Please run "composer install" in the plugin directory.', 'staticq-media');
            return false;
        }
        
        if (empty($this->bucket_name)) {
            $this->last_error = __('R2 bucket name not configured', 'staticq-media');
            return false;
        }
        
        if (empty($this->account_id)) {
            $this->last_error = __('R2 account ID not configured', 'staticq-media');
            return false;
        }
        
        $access_key = SQMedia_Settings::get_r2_access_key_id();
        $secret_key = SQMedia_Settings::get_r2_secret_access_key();
        
        if (empty($access_key) || empty($secret_key)) {
            $this->last_error = __('R2 API credentials not configured', 'staticq-media');
            return false;
        }
        
        try {
            // R2 S3-compatible endpoint
            $endpoint = 'https://' . $this->account_id . '.r2.cloudflarestorage.com';
            
            $this->client = new \Aws\S3\S3Client([
                'version' => 'latest',
                'region' => 'auto', // R2 uses 'auto' for region
                'endpoint' => $endpoint,
                'use_path_style_endpoint' => true,
                'credentials' => [
                    'key' => $access_key,
                    'secret' => $secret_key,
                ],
            ]);
            
            $this->initialized = true;
            return true;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            if (function_exists('sq')) {
                sqmedia()->log('R2 initialization failed: ' . $e->getMessage(), 'error');
            }
            return false;
        }
    }
    
    /**
     * Check if client is ready
     */
    public function is_ready(): bool {
        return $this->init();
    }
    
    /**
     * Get last error
     */
    public function get_last_error(): string {
        return $this->last_error;
    }
    
    /**
     * Test connection to bucket
     */
    public function test_connection(): array {
        if (!$this->init()) {
            return [
                'success' => false,
                'error' => $this->last_error,
            ];
        }
        
        try {
            // Try to head the bucket (check if accessible)
            $this->client->headBucket([
                'Bucket' => $this->bucket_name,
            ]);
            
            return [
                'success' => true,
                'message' => sprintf(
                    /* translators: %s: R2 bucket name */
                    __('Successfully connected to R2 bucket "%s"', 'staticq-media'),
                    $this->bucket_name
                ),
            ];
            
        } catch (\Aws\S3\Exception\S3Exception $e) {
            $error_code = $e->getAwsErrorCode();
            $status_code = $e->getStatusCode();
            
            // R2 returns 404 NotFound when bucket doesn't exist (not NoSuchBucket like AWS)
            if ($error_code === 'NoSuchBucket' || $error_code === 'NotFound' || $status_code === 404) {
                return [
                    'success' => false,
                    'error' => sprintf(
                        /* translators: %s: R2 bucket name */
                        __('Bucket "%s" does not exist. Please create the bucket in your Cloudflare R2 dashboard first.', 'staticq-media'),
                        $this->bucket_name
                    ),
                    'error_type' => 'bucket_not_found',
                    'help_url' => 'https://dash.cloudflare.com/?to=/:account/r2/new',
                ];
            }
            
            if ($error_code === 'AccessDenied' || $status_code === 403) {
                return [
                    'success' => false,
                    'error' => __('Access denied. Your R2 API token may not have permission to access this bucket. Please check that your token has "Object Read & Write" permission for this specific bucket.', 'staticq-media'),
                    'error_type' => 'access_denied',
                ];
            }
            
            if ($error_code === 'InvalidAccessKeyId' || $error_code === 'SignatureDoesNotMatch') {
                return [
                    'success' => false,
                    'error' => __('Invalid credentials. Please verify your Access Key ID and Secret Access Key are correct.', 'staticq-media'),
                    'error_type' => 'invalid_credentials',
                ];
            }
            
            // For any other S3 error, provide a cleaner message
            return [
                'success' => false,
                'error' => sprintf(
                    /* translators: %s: error message from R2 API */
                    __('R2 connection failed: %s', 'staticq-media'),
                    $this->sanitize_error_message($e->getMessage())
                ),
                'error_type' => 'unknown',
                'error_code' => $error_code,
            ];
            
        } catch (\Exception $e) {
            return [
                'success' => false,
                'error' => sprintf(
                    /* translators: %s: error message from connection attempt */
                    __('Connection error: %s', 'staticq-media'),
                    $this->sanitize_error_message($e->getMessage())
                ),
                'error_type' => 'exception',
            ];
        }
    }
    
    /**
     * Sanitize error messages for display
     * Removes internal URLs and technical details that would confuse users
     */
    private function sanitize_error_message(string $message): string {
        // Remove internal R2 endpoint URLs
        $message = preg_replace('#https://[a-f0-9]+\.r2\.cloudflarestorage\.com/[^"\';\s]*#i', '[R2 bucket]', $message);
        
        // Remove AWS error XML if present
        $message = preg_replace('#<\?xml.*?\?>#s', '', $message);
        $message = preg_replace('#<Error>.*?</Error>#s', '', $message);
        
        // Clean up multiple spaces and trim
        $message = preg_replace('/\s+/', ' ', $message);
        $message = trim($message);
        
        // If message is now empty or just punctuation, provide generic message
        if (empty($message) || preg_match('/^[\s\-:;]+$/', $message)) {
            return __('An unexpected error occurred. Please check your R2 configuration.', 'staticq-media');
        }
        
        return $message;
    }
    
    /**
     * Check if file exists in bucket
     */
    public function file_exists(string $path): bool {
        // Check cache first
        if (isset($this->existence_cache[$path])) {
            return $this->existence_cache[$path];
        }
        
        if (!$this->init()) {
            return false;
        }
        
        try {
            $this->client->headObject([
                'Bucket' => $this->bucket_name,
                'Key' => $path,
            ]);
            
            $this->existence_cache[$path] = true;
            return true;
            
        } catch (\Aws\S3\Exception\S3Exception $e) {
            if ($e->getAwsErrorCode() === 'NotFound' || $e->getAwsErrorCode() === 'NoSuchKey') {
                $this->existence_cache[$path] = false;
                return false;
            }
            
            // Log unexpected errors
            if (function_exists('sq')) {
                sqmedia()->log('R2 file_exists check failed: ' . $e->getMessage(), 'warning', ['path' => $path]);
            }
            return false;
            
        } catch (\Exception $e) {
            return false;
        }
    }
    
    /**
     * Upload file to bucket
     * 
     * Automatically uses multipart upload for files larger than threshold.
     * 
     * @param string $local_path Local file path
     * @param string $bucket_path Destination path in bucket
     * @param array $options Upload options (mime_type, cache_control, attachment_id)
     * @return bool True on success
     */
    public function upload_file(string $local_path, string $bucket_path, array $options = []): bool {
        if (!$this->init()) {
            return false;
        }
        
        if (!file_exists($local_path)) {
            $this->last_error = __('Local file does not exist', 'staticq-media');
            return false;
        }
        
        $file_size = filesize($local_path);
        
        // Use multipart upload for large files
        if (SQMedia_Settings::should_use_chunked_upload($file_size)) {
            return $this->upload_file_multipart($local_path, $bucket_path, $options);
        }
        
        // Direct upload for smaller files
        return $this->upload_file_direct($local_path, $bucket_path, $options);
    }
    
    /**
     * Direct upload for smaller files
     */
    private function upload_file_direct(string $local_path, string $bucket_path, array $options = []): bool {
        try {
            $upload_options = [
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'SourceFile' => $local_path,
            ];
            
            // Set content type
            $mime_type = $options['content_type'] ?? $options['mime_type'] ?? mime_content_type($local_path);
            if ($mime_type) {
                $upload_options['ContentType'] = $mime_type;
            }

            // Set cache control
            if (!empty($options['cache_control'])) {
                $upload_options['CacheControl'] = $options['cache_control'];
            } else {
                // Default: cache for 1 year
                $upload_options['CacheControl'] = 'public, max-age=31536000';
            }
            
            // Set ACL if public access is configured
            $public_access = SQMedia_Settings::get_r2_public_access();
            if ($public_access === 'public_bucket') {
                $upload_options['ACL'] = 'public-read';
            }
            
            $this->client->putObject($upload_options);
            
            // Update cache
            $this->existence_cache[$bucket_path] = true;
            
            if (function_exists('sq')) {
                sqmedia()->log('R2 upload successful', 'debug', [
                    'local' => basename($local_path),
                    'bucket_path' => $bucket_path,
                ]);
            }
            
            return true;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            if (function_exists('sq')) {
                sqmedia()->log('R2 upload failed: ' . $e->getMessage(), 'error', [
                    'local' => $local_path,
                    'bucket_path' => $bucket_path,
                ]);
            }
            return false;
        }
    }
    
    /**
     * Multipart upload for large files
     * 
     * Uses S3 multipart upload protocol. Progress is saved via transients
     * so upload can continue across multiple cron passes.
     */
    private function upload_file_multipart(string $local_path, string $bucket_path, array $options = []): bool {
        $transient_key = $this->get_multipart_upload_transient_key($local_path, $bucket_path);
        
        // Check for existing upload in progress
        $progress = get_transient($transient_key);
        
        if ($progress && !empty($progress['upload_id'])) {
            // Resume existing upload
            return $this->resume_multipart_upload($local_path, $bucket_path, $progress, $transient_key, $options);
        }
        
        // Start new multipart upload
        return $this->start_multipart_upload($local_path, $bucket_path, $transient_key, $options);
    }
    
    /**
     * Start a new multipart upload
     */
    private function start_multipart_upload(string $local_path, string $bucket_path, string $transient_key, array $options = []): bool {
        try {
            $file_size = filesize($local_path);
            $chunk_size = SQMedia_Settings::get_chunk_size();
            $mime_type = $options['content_type'] ?? $options['mime_type'] ?? mime_content_type($local_path);

            // Calculate total parts
            $total_parts = (int) ceil($file_size / $chunk_size);
            
            // Initiate multipart upload
            $init_options = [
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'ContentType' => $mime_type ?: 'application/octet-stream',
                'CacheControl' => $options['cache_control'] ?? 'public, max-age=31536000',
            ];
            
            $public_access = SQMedia_Settings::get_r2_public_access();
            if ($public_access === 'public_bucket') {
                $init_options['ACL'] = 'public-read';
            }
            
            $result = $this->client->createMultipartUpload($init_options);
            $upload_id = $result['UploadId'];
            
            // Save initial progress
            $progress = [
                'upload_id' => $upload_id,
                'local_path' => $local_path,
                'bucket_path' => $bucket_path,
                'file_size' => $file_size,
                'chunk_size' => $chunk_size,
                'total_parts' => $total_parts,
                'completed_parts' => [],
                'mime_type' => $mime_type,
                'started_at' => time(),
                'last_update' => time(),
            ];
            
            set_transient($transient_key, $progress, DAY_IN_SECONDS);
            
            // Now upload parts
            return $this->upload_multipart_parts($local_path, $bucket_path, $progress, $transient_key);
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            delete_transient($transient_key);
            
            if (function_exists('sq')) {
                sqmedia()->log('R2 multipart upload initiation failed: ' . $e->getMessage(), 'error', [
                    'local' => $local_path,
                    'bucket_path' => $bucket_path,
                ]);
            }
            
            return false;
        }
    }
    
    /**
     * Resume an interrupted multipart upload
     */
    private function resume_multipart_upload(string $local_path, string $bucket_path, array $progress, string $transient_key, array $options = []): bool {
        // Verify local file still exists and matches
        if (!file_exists($local_path) || filesize($local_path) !== $progress['file_size']) {
            // File changed - abort and start fresh
            $this->abort_multipart_upload_internal($progress['upload_id'], $bucket_path);
            delete_transient($transient_key);
            $this->last_error = __('File changed since upload started, restarting', 'staticq-media');
            return $this->start_multipart_upload($local_path, $bucket_path, $transient_key, $options);
        }
        
        return $this->upload_multipart_parts($local_path, $bucket_path, $progress, $transient_key);
    }
    
    /**
     * Upload multipart parts (works for both new and resumed uploads)
     * 
     * Uploads a limited number of parts per cron pass to avoid timeouts.
     */
    private function upload_multipart_parts(string $local_path, string $bucket_path, array $progress, string $transient_key): bool {
        try {
            $upload_id = $progress['upload_id'];
            $chunk_size = $progress['chunk_size'];
            $total_parts = $progress['total_parts'];
            $completed_parts = $progress['completed_parts'];
            
            // Determine which parts still need uploading
            $completed_part_numbers = array_column($completed_parts, 'PartNumber');
            
            // Limit parts per cron pass to avoid timeout (upload ~5 chunks per pass = ~50MB)
            $parts_per_pass = 5;
            $parts_uploaded_this_pass = 0;
            
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
            $file_handle = fopen($local_path, 'rb');
            
            for ($part_number = 1; $part_number <= $total_parts; $part_number++) {
                // Skip already completed parts
                if (in_array($part_number, $completed_part_numbers)) {
                    continue;
                }
                
                // Limit per pass
                if ($parts_uploaded_this_pass >= $parts_per_pass) {
                    // Save progress and return false to indicate not complete
                    // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
                    fclose($file_handle);
                    $progress['completed_parts'] = $completed_parts;
                    $progress['last_update'] = time();
                    set_transient($transient_key, $progress, DAY_IN_SECONDS);
                    
                    if (function_exists('sq')) {
                        sqmedia()->log('R2 multipart upload progress saved', 'debug', [
                            'bucket_path' => $bucket_path,
                            'completed' => count($completed_parts),
                            'total' => $total_parts,
                        ]);
                    }
                    
                    return false; // Not complete yet
                }
                
                // Seek to correct position
                $offset = ($part_number - 1) * $chunk_size;
                fseek($file_handle, $offset);
                
                // Read chunk
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread
                $chunk = fread($file_handle, $chunk_size);
                
                // Upload part
                $result = $this->client->uploadPart([
                    'Bucket' => $this->bucket_name,
                    'Key' => $bucket_path,
                    'UploadId' => $upload_id,
                    'PartNumber' => $part_number,
                    'Body' => $chunk,
                ]);
                
                // Track completed part
                $completed_parts[] = [
                    'PartNumber' => $part_number,
                    'ETag' => $result['ETag'],
                ];
                
                $parts_uploaded_this_pass++;
            }
            
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
            fclose($file_handle);

            // All parts uploaded - complete the upload
            $this->client->completeMultipartUpload([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'UploadId' => $upload_id,
                'MultipartUpload' => [
                    'Parts' => $completed_parts,
                ],
            ]);
            
            // Cleanup
            $this->existence_cache[$bucket_path] = true;
            delete_transient($transient_key);
            
            if (function_exists('sq')) {
                sqmedia()->log('R2 multipart upload completed', 'info', [
                    'bucket_path' => $bucket_path,
                    'size' => $progress['file_size'],
                    'parts' => $total_parts,
                ]);
            }
            
            return true;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            
            // Save progress even on error (might be recoverable)
            if (isset($completed_parts)) {
                $progress['completed_parts'] = $completed_parts;
                $progress['last_update'] = time();
                $progress['last_error'] = $e->getMessage();
                set_transient($transient_key, $progress, DAY_IN_SECONDS);
            }
            
            if (function_exists('sq')) {
                sqmedia()->log('R2 multipart upload error: ' . $e->getMessage(), 'error', [
                    'bucket_path' => $bucket_path,
                ]);
            }
            
            return false;
        }
    }
    
    /**
     * Get transient key for multipart upload progress
     */
    private function get_multipart_upload_transient_key(string $local_path, string $bucket_path): string {
        return 'sqmedia_multipart_r2_' . md5($local_path . '|' . $bucket_path);
    }
    
    /**
     * Internal method to abort a multipart upload
     */
    private function abort_multipart_upload_internal(string $upload_id, string $bucket_path): bool {
        try {
            $this->client->abortMultipartUpload([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'UploadId' => $upload_id,
            ]);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }
    
    /**
     * Abort a multipart upload in progress
     */
    public function abort_chunked_upload(string $local_path, string $bucket_path): bool {
        $transient_key = $this->get_multipart_upload_transient_key($local_path, $bucket_path);
        $progress = get_transient($transient_key);
        
        if ($progress && !empty($progress['upload_id'])) {
            $this->abort_multipart_upload_internal($progress['upload_id'], $bucket_path);
        }
        
        return delete_transient($transient_key);
    }
    
    /**
     * Check if a multipart upload is in progress
     */
    public function has_pending_chunked_upload(string $local_path, string $bucket_path): bool {
        $transient_key = $this->get_multipart_upload_transient_key($local_path, $bucket_path);
        return get_transient($transient_key) !== false;
    }
    
    /**
     * Upload file from string content
     */
    public function upload_string(string $content, string $bucket_path, array $options = []): bool {
        if (!$this->init()) {
            return false;
        }
        
        try {
            $upload_options = [
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'Body' => $content,
            ];
            
            $mime_type = $options['content_type'] ?? $options['mime_type'] ?? '';
            if (!empty($mime_type)) {
                $upload_options['ContentType'] = $mime_type;
            }

            if (!empty($options['cache_control'])) {
                $upload_options['CacheControl'] = $options['cache_control'];
            } else {
                $upload_options['CacheControl'] = 'public, max-age=31536000';
            }

            $public_access = SQMedia_Settings::get_r2_public_access();
            if ($public_access === 'public_bucket') {
                $upload_options['ACL'] = 'public-read';
            }

            $this->client->putObject($upload_options);

            $this->existence_cache[$bucket_path] = true;

            return true;

        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return false;
        }
    }

    /**
     * Upload from string content (alias for upload_string)
     */
    public function upload_content(string $content, string $bucket_path, array $options = []): bool {
        return $this->upload_string($content, $bucket_path, $options);
    }
    
    /**
     * Delete file from bucket
     */
    public function delete_file(string $bucket_path): bool {
        if (!$this->init()) {
            return false;
        }
        
        try {
            $this->client->deleteObject([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
            ]);
            
            // Update cache
            unset($this->existence_cache[$bucket_path]);
            
            return true;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return false;
        }
    }
    
    /**
     * Delete multiple files from bucket
     */
    public function delete_files(array $bucket_paths): array {
        if (!$this->init()) {
            return ['deleted' => [], 'failed' => $bucket_paths];
        }
        
        if (empty($bucket_paths)) {
            return ['deleted' => [], 'failed' => []];
        }
        
        $deleted = [];
        $failed = [];
        
        // R2 supports batch delete via DeleteObjects
        try {
            $objects = array_map(function($path) {
                return ['Key' => $path];
            }, $bucket_paths);
            
            $result = $this->client->deleteObjects([
                'Bucket' => $this->bucket_name,
                'Delete' => [
                    'Objects' => $objects,
                    'Quiet' => false,
                ],
            ]);
            
            // Track deleted
            if (!empty($result['Deleted'])) {
                foreach ($result['Deleted'] as $obj) {
                    $deleted[] = $obj['Key'];
                    unset($this->existence_cache[$obj['Key']]);
                }
            }
            
            // Track errors
            if (!empty($result['Errors'])) {
                foreach ($result['Errors'] as $error) {
                    $failed[] = $error['Key'];
                }
            }
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return ['deleted' => [], 'failed' => $bucket_paths];
        }
        
        return ['deleted' => $deleted, 'failed' => $failed];
    }
    
    /**
     * List files in bucket with prefix
     */
    public function list_files(string $prefix = ''): array {
        if (!$this->init()) {
            return [];
        }
        
        try {
            $files = [];
            $params = [
                'Bucket' => $this->bucket_name,
            ];
            
            if (!empty($prefix)) {
                $params['Prefix'] = $prefix;
            }
            
            // Use paginator for large listings
            $paginator = $this->client->getPaginator('ListObjectsV2', $params);
            
            foreach ($paginator as $page) {
                if (!empty($page['Contents'])) {
                    foreach ($page['Contents'] as $object) {
                        // Skip "folder markers" — zero-byte placeholder objects with
                        // keys ending in '/'. The Cloudflare R2 console (and some other
                        // S3 tools) create these when a user manually adds a folder.
                        // They are not real files; the actual contents are returned as
                        // separate flat keys by listObjectsV2 anyway.
                        if (substr($object['Key'], -1) === '/') {
                            continue;
                        }
                        $files[] = [
                            'name' => $object['Key'],
                            'size' => (int) ($object['Size'] ?? 0),
                            'updated' => $object['LastModified'] ?? null,
                        ];
                    }
                }
            }

            return $files;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return [];
        }
    }
    
    /**
     * List files with pagination
     */
    public function list_files_paginated(string $prefix, int $batch_size, ?string $continuation_token = null): array {
        if (!$this->init()) {
            return ['files' => [], 'next_page_token' => null];
        }
        
        try {
            $params = [
                'Bucket' => $this->bucket_name,
                'MaxKeys' => $batch_size,
            ];
            
            if (!empty($prefix)) {
                $params['Prefix'] = $prefix;
            }
            
            if ($continuation_token) {
                $params['ContinuationToken'] = $continuation_token;
            }
            
            $result = $this->client->listObjectsV2($params);
            
            $files = [];
            if (!empty($result['Contents'])) {
                foreach ($result['Contents'] as $object) {
                    // Skip folder-marker placeholder objects (key ends in '/').
                    if (substr($object['Key'], -1) === '/') {
                        continue;
                    }
                    $files[] = [
                        'name' => $object['Key'],
                        'size' => (int) ($object['Size'] ?? 0),
                        'updated' => $object['LastModified'] ?? null,
                    ];
                }
            }
            
            $next_token = null;
            if (!empty($result['IsTruncated']) && !empty($result['NextContinuationToken'])) {
                $next_token = $result['NextContinuationToken'];
            }
            
            return [
                'files' => $files,
                'next_page_token' => $next_token,
            ];
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return ['files' => [], 'next_page_token' => null];
        }
    }
    
    /**
     * Stream files from bucket to a callback function
     * Efficient for large buckets - doesn't load all files into memory
     */
    public function stream_files(string $prefix, callable $callback, int $limit = 0): int {
        if (!$this->init()) {
            return 0;
        }
        
        try {
            $params = [
                'Bucket' => $this->bucket_name,
            ];
            
            if (!empty($prefix)) {
                $params['Prefix'] = $prefix;
            }
            
            if ($limit > 0) {
                $params['MaxKeys'] = $limit;
            }
            
            $paginator = $this->client->getPaginator('ListObjectsV2', $params);
            
            $count = 0;
            
            foreach ($paginator as $page) {
                if (empty($page['Contents'])) {
                    continue;
                }
                
                foreach ($page['Contents'] as $object) {
                    // Skip folder-marker placeholder objects (key ends in '/').
                    if (substr($object['Key'], -1) === '/') {
                        continue;
                    }
                    $file = [
                        'name' => $object['Key'],
                        'size' => (int) ($object['Size'] ?? 0),
                        'updated' => $object['LastModified'] ?? null,
                    ];

                    // Call the callback - stop if it returns false
                    if ($callback($file) === false) {
                        return $count;
                    }

                    $count++;
                    
                    if ($limit > 0 && $count >= $limit) {
                        return $count;
                    }
                }
            }
            
            return $count;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return 0;
        }
    }
    
    /**
     * Copy file within bucket
     */
    public function copy_file(string $source_path, string $dest_path): bool {
        if (!$this->init()) {
            return false;
        }
        
        try {
            // CopySource is treated as a URI by S3/R2, so path segments must be URL-encoded.
            // We encode each segment individually to preserve '/' separators.
            $encoded_source = implode('/', array_map('rawurlencode', explode('/', $source_path)));
            $this->client->copyObject([
                'Bucket' => $this->bucket_name,
                'CopySource' => $this->bucket_name . '/' . $encoded_source,
                'Key' => $dest_path,
            ]);
            
            $this->existence_cache[$dest_path] = true;
            
            return true;
            
        } catch (\Aws\S3\Exception\S3Exception $e) {
            if ($e->getAwsErrorCode() === 'NoSuchKey') {
                $this->last_error = __('Source file does not exist', 'staticq-media');
            } else {
                $this->last_error = $e->getMessage();
            }
            return false;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return false;
        }
    }
    
    /**
     * Rename (move) file within bucket (copy + delete)
     *
     * R2/S3 has no native rename, so this copies to the new path then deletes the original.
     *
     * @param string $source_path Source path in bucket
     * @param string $dest_path Destination path in bucket
     * @return bool True on success
     */
    public function rename_file(string $source_path, string $dest_path): bool {
        if (!$this->copy_file($source_path, $dest_path)) {
            return false;
        }
        return $this->delete_file($source_path);
    }

    /**
     * Get file info from bucket
     */
    public function get_file_info(string $bucket_path): ?array {
        if (!$this->init()) {
            return null;
        }
        
        try {
            $result = $this->client->headObject([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
            ]);
            
            return [
                'name' => $bucket_path,
                'size' => (int) ($result['ContentLength'] ?? 0),
                'content_type' => $result['ContentType'] ?? '',
                'updated' => $result['LastModified'] ?? null,
                'etag' => trim($result['ETag'] ?? '', '"'),
            ];
            
        } catch (\Aws\S3\Exception\S3Exception $e) {
            if ($e->getAwsErrorCode() === 'NotFound' || $e->getAwsErrorCode() === 'NoSuchKey') {
                return null;
            }
            $this->last_error = $e->getMessage();
            return null;
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return null;
        }
    }
    
    /**
     * Get public URL for file
     */
    public function get_public_url(string $bucket_path): string {
        $prefix = SQMedia_Settings::get_r2_prefix();
        $relative_path = $bucket_path;
        
        // Remove prefix if present
        if (!empty($prefix) && strpos($bucket_path, $prefix . '/') === 0) {
            $relative_path = substr($bucket_path, strlen($prefix) + 1);
        }
        
        return SQMedia_Settings::build_r2_url($relative_path);
    }
    
    /**
     * Download file from bucket
     */
    public function download_file(string $bucket_path, string $local_path): bool {
        if (!$this->init()) {
            return false;
        }
        
        try {
            // Ensure directory exists
            $dir = dirname($local_path);
            if (!is_dir($dir)) {
                wp_mkdir_p($dir);
            }
            
            $this->client->getObject([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
                'SaveAs' => $local_path,
            ]);
            
            return file_exists($local_path);
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return false;
        }
    }
    
    /**
     * Get file content as string
     */
    public function get_file_content(string $bucket_path): ?string {
        if (!$this->init()) {
            return null;
        }
        
        try {
            $result = $this->client->getObject([
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
            ]);
            
            return (string) $result['Body'];
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return null;
        }
    }
    
    /**
     * Generate a presigned URL for temporary access
     */
    public function get_presigned_url(string $bucket_path, int $expires_in = 3600): ?string {
        if (!$this->init()) {
            return null;
        }
        
        try {
            $cmd = $this->client->getCommand('GetObject', [
                'Bucket' => $this->bucket_name,
                'Key' => $bucket_path,
            ]);
            
            $request = $this->client->createPresignedRequest($cmd, "+{$expires_in} seconds");
            
            return (string) $request->getUri();
            
        } catch (\Exception $e) {
            $this->last_error = $e->getMessage();
            return null;
        }
    }
    
    /**
     * Clear existence cache
     */
    public function clear_cache(): void {
        $this->existence_cache = [];
    }
    
    /**
     * Batch check file existence
     */
    public function batch_file_exists(array $paths): array {
        $results = [];
        
        foreach ($paths as $path) {
            $results[$path] = $this->file_exists($path);
        }
        
        return $results;
    }
    
    /**
     * Get bucket name
     */
    public function get_bucket_name(): string {
        return $this->bucket_name;
    }

    /**
     * Check if bucket is empty (has no objects)
     * Returns null if unable to check (connection error, etc.)
     *
     * @return bool|null True if empty, false if has files, null on error
     */
    public function is_bucket_empty(): ?bool {
        if (!$this->init()) {
            return null;
        }

        try {
            $result = $this->client->listObjectsV2([
                'Bucket' => $this->bucket_name,
                'MaxKeys' => 1, // We only need to know if there's at least one file
            ]);

            $contents = $result->get('Contents') ?? [];
            return empty($contents);

        } catch (\Exception $e) {
            return null;
        }
    }

    /**
     * Get the S3 client (for advanced operations)
     */
    public function get_client(): ?\Aws\S3\S3Client {
        if (!$this->init()) {
            return null;
        }
        return $this->client;
    }
    
    /**
     * Test if the bucket allows public access via HTTP
     * 
     * Tests whether files in the bucket can be accessed publicly via URL.
     * Uses a known file path to verify access.
     */
    public function test_bucket_public_access(string $test_path): bool {
        $url = $this->get_public_url($test_path);
        
        if (empty($url)) {
            return false;
        }
        
        // Do a HEAD request to check access
        $response = wp_remote_head($url, [
            'timeout' => 10,
            'redirection' => 0,
        ]);
        
        $is_public = false;
        
        if (is_wp_error($response)) {
            $is_public = false;
        } else {
            $code = wp_remote_retrieve_response_code($response);
            // 200 = public, 403 = private, 404 = file not found (but bucket could still be public)
            $is_public = ($code === 200 || $code === 206);
        }
        
        // Cache result for 1 hour
        $transient_key = 'sqmedia_bucket_public_' . md5($this->bucket_name);
        set_transient($transient_key, $is_public ? 'yes' : 'no', HOUR_IN_SECONDS);
        
        if (function_exists('sq')) {
            sqmedia()->log('R2 bucket public access test', 'debug', [
                'bucket' => $this->bucket_name,
                'is_public' => $is_public,
                'test_url' => $url,
            ]);
        }
        
        return $is_public;
    }
}