<?php

namespace WHPBackup;

use Exception;

class BackupStorage {
    private $config;
    private $s3Client;
    private $multipartThreshold = 104857600; // 100MB default threshold for multipart uploads

    public function __construct($config) {
        $this->config = $config;

        // Allow configuration of multipart threshold
        if (isset($config['multipart_threshold'])) {
            $this->multipartThreshold = $config['multipart_threshold'];
        }

        if (($config['type'] ?? 's3') === 's3') {
            $this->initializeS3Client();
        }
    }
    
    private function initializeS3Client() {
        $this->s3Client = new S3Client($this->config);
    }
    
    public function uploadFile($localPath, $remotePath) {
        try {
            // Construct full path with prefix
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($remotePath, '/');
            } else {
                $fullPath = ltrim($remotePath, '/');
            }

            // Check file size to determine upload method
            $fileSize = filesize($localPath);

            // Use multipart upload for files larger than threshold
            if ($fileSize > $this->multipartThreshold) {
                error_log("Using multipart upload for large file: {$localPath} (" . number_format($fileSize/1024/1024, 2) . " MB)");
                $result = $this->s3Client->multipartUpload([
                    'Bucket' => $this->config['bucket'],
                    'Key' => $fullPath,
                    'SourceFile' => $localPath,
                    'BackupId' => $this->config['backup_id'] ?? null
                ]);
            } else {
                // Use regular upload for smaller files
                $result = $this->s3Client->putObject([
                    'Bucket' => $this->config['bucket'],
                    'Key' => $fullPath,
                    'SourceFile' => $localPath
                ]);
            }

            return [
                'success' => true,
                'path' => $fullPath,
                'size' => $fileSize,
                'etag' => $result['ETag'] ?? null
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function uploadFileViaRclone($localPath, $remotePath) {
        $type = $this->config['type'] ?? 's3';

        // Fall back to PHP upload if rclone is not installed (S3 only)
        if (!$this->isRcloneAvailable()) {
            if ($type === 's3') {
                error_log("rclone not found, falling back to PHP upload");
                return $this->uploadFile($localPath, $remotePath);
            }
            throw new Exception("rclone is required for {$type} uploads but is not installed");
        }

        try {
            // Construct full path with prefix
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($remotePath, '/');
            } else {
                $fullPath = ltrim($remotePath, '/');
            }

            $fileSize = filesize($localPath);
            $destination = $this->buildRcloneDestination($fullPath);
            $env = $this->buildRcloneEnv();
            $bwlimit = $this->getRcloneBwLimit();

            // Build rclone command with type-specific flags
            $flags = ' --retries 3' .
                ' --low-level-retries 10' .
                ' --retries-sleep 10s' .
                ' --stats-one-line' .
                ' --stats 30s' .
                ' --log-file /var/log/whp-backup.log' .
                ' --log-level INFO' .
                ' --bwlimit ' . escapeshellarg($bwlimit) .
                ' --checksum';

            if ($type === 's3') {
                $flags .= ' --s3-no-check-bucket --s3-chunk-size 64M --s3-upload-concurrency 4';
            }

            $cmd = sprintf(
                'rclone copyto %s %s%s 2>&1',
                escapeshellarg($localPath),
                escapeshellarg($destination),
                $flags
            );

            error_log("Starting rclone upload: {$localPath} -> {$destination} (" . number_format($fileSize/1024/1024, 2) . " MB)");

            $result = $this->executeRclone($cmd, $env);

            error_log("rclone upload completed successfully: {$localPath}");

            return [
                'success' => true,
                'path' => $fullPath,
                'size' => $fileSize,
                'etag' => null
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function isRcloneAvailable() {
        static $available = null;
        if ($available === null) {
            exec('which rclone 2>/dev/null', $output, $exitCode);
            $available = ($exitCode === 0);
        }
        return $available;
    }

    private function buildRcloneEnv() {
        $type = $this->config['type'] ?? 's3';
        $env = [
            'HOME' => '/root',
            'PATH' => '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
        ];

        if ($type === 's3') {
            $env['RCLONE_CONFIG_REMOTE_TYPE'] = 's3';
            $env['RCLONE_CONFIG_REMOTE_PROVIDER'] = 'Other';
            $env['RCLONE_CONFIG_REMOTE_ACCESS_KEY_ID'] = $this->config['access_key'];
            $env['RCLONE_CONFIG_REMOTE_SECRET_ACCESS_KEY'] = $this->config['secret_key'];
            $env['RCLONE_CONFIG_REMOTE_ENDPOINT'] = $this->config['endpoint'];
            $env['RCLONE_CONFIG_REMOTE_REGION'] = $this->config['region'] ?? 'us-east-1';
            $env['RCLONE_CONFIG_REMOTE_ACL'] = 'private';
            $env['RCLONE_CONFIG_REMOTE_NO_CHECK_BUCKET'] = 'true';
        } elseif ($type === 'ftp') {
            $env['RCLONE_CONFIG_REMOTE_TYPE'] = 'ftp';
            $env['RCLONE_CONFIG_REMOTE_HOST'] = $this->config['host'];
            $env['RCLONE_CONFIG_REMOTE_PORT'] = (string)($this->config['port'] ?? 21);
            $env['RCLONE_CONFIG_REMOTE_USER'] = $this->config['username'];
            $env['RCLONE_CONFIG_REMOTE_PASS'] = $this->obscurePassword($this->config['password']);
        } elseif ($type === 'sftp') {
            $env['RCLONE_CONFIG_REMOTE_TYPE'] = 'sftp';
            $env['RCLONE_CONFIG_REMOTE_HOST'] = $this->config['host'];
            $env['RCLONE_CONFIG_REMOTE_PORT'] = (string)($this->config['port'] ?? 22);
            $env['RCLONE_CONFIG_REMOTE_USER'] = $this->config['username'];
            $env['RCLONE_CONFIG_REMOTE_PASS'] = $this->obscurePassword($this->config['password']);
        }

        return $env;
    }

    private function buildRcloneDestination($fullPath) {
        $type = $this->config['type'] ?? 's3';

        if ($type === 's3') {
            $bucket = $this->config['bucket'];
            return "remote:{$bucket}/{$fullPath}";
        }

        // FTP/SFTP: use remote_path as base
        $remotePath = rtrim($this->config['remote_path'] ?? '/', '/');
        return "remote:{$remotePath}/{$fullPath}";
    }

    private function getRcloneBwLimit() {
        $bwlimit = '0';
        if (file_exists('/docker/whp/settings.json')) {
            $settings = json_decode(file_get_contents('/docker/whp/settings.json'), true);
            if ($settings && !empty($settings['rclone_bwlimit'])) {
                if (preg_match('/^\d+(\.\d+)?\s*(k|M|G|Ki|Mi|Gi)?$/', $settings['rclone_bwlimit'])) {
                    $bwlimit = $settings['rclone_bwlimit'];
                }
            }
        }
        return $bwlimit;
    }

    private function obscurePassword($password) {
        // rclone requires passwords to be obscured when passed via env vars
        $output = [];
        $exitCode = 0;
        exec('rclone obscure ' . escapeshellarg($password) . ' 2>/dev/null', $output, $exitCode);
        if ($exitCode === 0 && !empty($output[0])) {
            return $output[0];
        }
        // If obscure fails, return empty to avoid leaking plaintext
        throw new Exception("Failed to obscure password for rclone");
    }

    private function executeRclone($cmd, $env) {
        $descriptors = [
            0 => ['pipe', 'r'],
            1 => ['pipe', 'w'],
            2 => ['pipe', 'w'],
        ];

        $process = proc_open($cmd, $descriptors, $pipes, '/tmp', $env);

        if (!is_resource($process)) {
            throw new Exception("Failed to start rclone process");
        }

        fclose($pipes[0]);

        $stdout = stream_get_contents($pipes[1]);
        fclose($pipes[1]);

        $stderr = stream_get_contents($pipes[2]);
        fclose($pipes[2]);

        $exitCode = proc_close($process);

        if ($exitCode !== 0) {
            $errorOutput = trim($stderr ?: $stdout);
            throw new Exception("rclone failed (exit code {$exitCode}): {$errorOutput}");
        }

        return $stdout;
    }

    private function isNonS3Type() {
        return ($this->config['type'] ?? 's3') !== 's3';
    }

    public function downloadFile($remotePath, $localPath) {
        if ($this->isNonS3Type()) {
            return $this->downloadFileViaRclone($remotePath, $localPath);
        }

        try {
            $fullPath = $this->config['path_prefix'] . '/' . ltrim($remotePath, '/');

            $result = $this->s3Client->getObject([
                'Bucket' => $this->config['bucket'],
                'Key' => $fullPath,
                'SaveAs' => $localPath
            ]);

            return [
                'success' => true,
                'path' => $localPath,
                'size' => filesize($localPath)
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function downloadFileViaRclone($remotePath, $localPath) {
        try {
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($remotePath, '/');
            } else {
                $fullPath = ltrim($remotePath, '/');
            }

            $source = $this->buildRcloneDestination($fullPath);
            $env = $this->buildRcloneEnv();

            $cmd = sprintf(
                'rclone copyto %s %s --retries 3 --low-level-retries 10 2>&1',
                escapeshellarg($source),
                escapeshellarg($localPath)
            );

            $this->executeRclone($cmd, $env);

            return [
                'success' => true,
                'path' => $localPath,
                'size' => filesize($localPath)
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function streamFile($remotePath, $outputCallback = null) {
        if ($this->isNonS3Type()) {
            // For non-S3: download to temp file, then stream it
            $tmpFile = tempnam('/tmp', 'whp_stream_');
            try {
                $result = $this->downloadFileViaRclone($remotePath, $tmpFile);
                if (!$result['success']) {
                    throw new Exception($result['error']);
                }

                $fp = fopen($tmpFile, 'rb');
                if (!$fp) {
                    throw new Exception("Could not open temp file for streaming");
                }

                $size = filesize($tmpFile);
                while (!feof($fp)) {
                    $chunk = fread($fp, 8192);
                    if ($outputCallback && is_callable($outputCallback)) {
                        $outputCallback($chunk);
                    } else {
                        echo $chunk;
                        flush();
                    }
                }
                fclose($fp);
                unlink($tmpFile);

                return [
                    'success' => true,
                    'size' => $size
                ];
            } catch (Exception $e) {
                if (file_exists($tmpFile)) {
                    unlink($tmpFile);
                }
                return [
                    'success' => false,
                    'error' => $e->getMessage()
                ];
            }
        }

        try {
            $fullPath = $this->config['path_prefix'] . '/' . ltrim($remotePath, '/');

            $result = $this->s3Client->streamObject([
                'Bucket' => $this->config['bucket'],
                'Key' => $fullPath,
                'Callback' => $outputCallback
            ]);

            return [
                'success' => true,
                'size' => $result['size']
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function deleteFile($remotePath) {
        if ($this->isNonS3Type()) {
            return $this->deleteFileViaRclone($remotePath);
        }

        try {
            $fullPath = $this->config['path_prefix'] . '/' . ltrim($remotePath, '/');

            $this->s3Client->deleteObject([
                'Bucket' => $this->config['bucket'],
                'Key' => $fullPath
            ]);

            return [
                'success' => true,
                'path' => $fullPath
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function deleteFileViaRclone($remotePath) {
        try {
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($remotePath, '/');
            } else {
                $fullPath = ltrim($remotePath, '/');
            }

            $target = $this->buildRcloneDestination($fullPath);
            $env = $this->buildRcloneEnv();

            $cmd = sprintf('rclone deletefile %s 2>&1', escapeshellarg($target));
            $this->executeRclone($cmd, $env);

            return [
                'success' => true,
                'path' => $fullPath
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function listFiles($prefix = '') {
        if ($this->isNonS3Type()) {
            return $this->listFilesViaRclone($prefix);
        }

        try {
            $fullPrefix = $this->config['path_prefix'] . '/' . ltrim($prefix, '/');

            $result = $this->s3Client->listObjects([
                'Bucket' => $this->config['bucket'],
                'Prefix' => $fullPrefix
            ]);

            $files = [];
            if (isset($result['Contents'])) {
                foreach ($result['Contents'] as $object) {
                    $files[] = [
                        'key' => $object['Key'],
                        'size' => $object['Size'],
                        'modified' => $object['LastModified']
                    ];
                }
            }

            return [
                'success' => true,
                'files' => $files
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function listFilesViaRclone($prefix = '') {
        try {
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($prefix, '/');
            } else {
                $fullPath = ltrim($prefix, '/');
            }

            $target = $this->buildRcloneDestination($fullPath);
            $env = $this->buildRcloneEnv();

            $cmd = sprintf('rclone lsjson %s --recursive 2>&1', escapeshellarg($target));
            $output = $this->executeRclone($cmd, $env);

            $items = json_decode($output, true);
            $files = [];
            if (is_array($items)) {
                foreach ($items as $item) {
                    if (!($item['IsDir'] ?? false)) {
                        $files[] = [
                            'key' => $item['Path'],
                            'size' => $item['Size'] ?? 0,
                            'modified' => $item['ModTime'] ?? ''
                        ];
                    }
                }
            }

            return [
                'success' => true,
                'files' => $files
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function getFileInfo($remotePath) {
        if ($this->isNonS3Type()) {
            return $this->getFileInfoViaRclone($remotePath);
        }

        try {
            $fullPath = $this->config['path_prefix'] . '/' . ltrim($remotePath, '/');

            $result = $this->s3Client->headObject([
                'Bucket' => $this->config['bucket'],
                'Key' => $fullPath
            ]);

            return [
                'success' => true,
                'exists' => true,
                'size' => $result['ContentLength'],
                'modified' => $result['LastModified'],
                'etag' => $result['ETag']
            ];

        } catch (Exception $e) {
            if (strpos($e->getMessage(), '404') !== false) {
                return [
                    'success' => true,
                    'exists' => false
                ];
            }

            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function getFileInfoViaRclone($remotePath) {
        try {
            if (!empty($this->config['path_prefix'])) {
                $fullPath = rtrim($this->config['path_prefix'], '/') . '/' . ltrim($remotePath, '/');
            } else {
                $fullPath = ltrim($remotePath, '/');
            }

            $target = $this->buildRcloneDestination($fullPath);
            $env = $this->buildRcloneEnv();

            $cmd = sprintf('rclone lsjson %s 2>&1', escapeshellarg($target));
            $output = $this->executeRclone($cmd, $env);

            $items = json_decode($output, true);
            if (is_array($items) && !empty($items)) {
                $item = $items[0];
                return [
                    'success' => true,
                    'exists' => true,
                    'size' => $item['Size'] ?? 0,
                    'modified' => $item['ModTime'] ?? '',
                    'etag' => null
                ];
            }

            return [
                'success' => true,
                'exists' => false
            ];

        } catch (Exception $e) {
            // If rclone fails with file not found, return exists=false
            if (strpos($e->getMessage(), 'not found') !== false ||
                strpos($e->getMessage(), 'directory not found') !== false) {
                return [
                    'success' => true,
                    'exists' => false
                ];
            }

            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
    
    public function testConnection() {
        if ($this->isNonS3Type()) {
            return $this->testConnectionViaRclone();
        }

        try {
            $this->s3Client->listObjects([
                'Bucket' => $this->config['bucket'],
                'MaxKeys' => 1
            ]);

            return [
                'success' => true,
                'message' => 'Connection successful'
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }

    private function testConnectionViaRclone() {
        try {
            $env = $this->buildRcloneEnv();
            $remotePath = rtrim($this->config['remote_path'] ?? '/', '/');

            $cmd = sprintf('rclone lsd %s --max-depth 1 2>&1', escapeshellarg("remote:{$remotePath}"));
            $this->executeRclone($cmd, $env);

            return [
                'success' => true,
                'message' => 'Connection successful'
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage()
            ];
        }
    }
}

/**
 * Simple S3-compatible client implementation
 */
class S3Client {
    private $config;
    
    public function __construct($config) {
        $this->config = $config;
    }
    
    /**
     * Multipart upload for large files with resume capability
     * Splits files into chunks and uploads them with progress tracking
     */
    public function multipartUpload($params) {
        $bucket = $params['Bucket'];
        $key = $params['Key'];
        $sourceFile = $params['SourceFile'];
        $fileSize = filesize($sourceFile);
        $backupId = $params['BackupId'] ?? null;

        // Determine optimal chunk size (between 5MB and 100MB)
        // AWS requires minimum 5MB chunks except for last chunk
        $chunkSize = $this->calculateOptimalChunkSize($fileSize);
        $numParts = ceil($fileSize / $chunkSize);

        error_log("Starting multipart upload - Bucket: {$bucket}, Key: {$key}, Size: " . number_format($fileSize/1024/1024, 2) . " MB in {$numParts} parts of " . number_format($chunkSize/1024/1024, 2) . " MB each");

        // Check if we can resume an existing upload
        $existingUpload = null;
        if ($backupId) {
            $existingUpload = $this->findExistingUpload($backupId, $bucket, $key);
        }

        $uploadId = null;
        $completedParts = [];

        if ($existingUpload) {
            // Resume existing upload
            $uploadId = $existingUpload['upload_id'];
            $completedParts = $existingUpload['completed_parts'];
            error_log("Resuming multipart upload {$uploadId} with " . count($completedParts) . " completed parts");
        } else {
            // Step 1: Initiate new multipart upload
            $uploadId = $this->initiateMultipartUpload($bucket, $key);

            // Track the upload in database if backup ID provided
            if ($backupId) {
                $this->trackMultipartUpload($backupId, $uploadId, $bucket, $key, $sourceFile, $fileSize, $chunkSize, $numParts);
            }
        }

        try {
            $parts = $completedParts;
            $completedPartNumbers = array_column($completedParts, 'PartNumber');

            $fp = fopen($sourceFile, 'rb');
            if (!$fp) {
                throw new Exception("Could not open source file: $sourceFile");
            }

            // Step 2: Upload parts (skip already completed ones)
            for ($partNumber = 1; $partNumber <= $numParts; $partNumber++) {
                // Skip if already uploaded
                if (in_array($partNumber, $completedPartNumbers)) {
                    error_log("Skipping already uploaded part {$partNumber}/{$numParts}");
                    // Seek to next part position
                    fseek($fp, $partNumber * $chunkSize);
                    continue;
                }

                $partStartTime = microtime(true);

                // Seek to correct position in file
                fseek($fp, ($partNumber - 1) * $chunkSize);

                // Calculate part size (last part may be smaller)
                $currentPartSize = min($chunkSize, $fileSize - (($partNumber - 1) * $chunkSize));

                // Read chunk from file
                $chunkData = fread($fp, $currentPartSize);
                if ($chunkData === false) {
                    throw new Exception("Failed to read chunk {$partNumber}: fread returned false");
                }

                $actualSize = strlen($chunkData);
                if ((int)$actualSize !== (int)$currentPartSize) {
                    throw new Exception("Failed to read chunk {$partNumber}: expected {$currentPartSize} bytes, got {$actualSize} bytes (file position: " . ftell($fp) . ", file size: {$fileSize})");
                }

                // Upload part with retry logic
                $maxRetries = 3;
                $etag = null;

                for ($retry = 0; $retry < $maxRetries; $retry++) {
                    try {
                        $etag = $this->uploadPart($bucket, $key, $uploadId, $partNumber, $chunkData);
                        break;
                    } catch (Exception $e) {
                        if ($retry === $maxRetries - 1) {
                            throw $e;
                        }
                        error_log("Part {$partNumber} upload failed (attempt " . ($retry + 1) . "/{$maxRetries}): " . $e->getMessage());
                        sleep(2 ** $retry); // Exponential backoff
                    }
                }

                $parts[] = ['PartNumber' => $partNumber, 'ETag' => $etag];

                // Update progress in database
                if ($backupId) {
                    $this->updateMultipartProgress($uploadId, $parts);
                }

                $partTime = microtime(true) - $partStartTime;
                $partSpeed = ($currentPartSize / 1024 / 1024) / $partTime;
                $progress = ($partNumber / $numParts) * 100;
                error_log("Uploaded part {$partNumber}/{$numParts} (" . number_format($currentPartSize/1024/1024, 2) . " MB) in " . number_format($partTime, 2) . "s (" . number_format($partSpeed, 2) . " MB/s) - " . number_format($progress, 1) . "% complete");
            }

            fclose($fp);

            // Sort parts by part number for completion
            usort($parts, function($a, $b) {
                return $a['PartNumber'] - $b['PartNumber'];
            });

            // Step 3: Complete multipart upload
            $result = $this->completeMultipartUpload($bucket, $key, $uploadId, $parts);

            // Mark upload as completed in database
            if ($backupId) {
                $this->markMultipartComplete($uploadId);
            }

            error_log("Multipart upload completed successfully for {$key}");
            return ['ETag' => $result['ETag'] ?? null, 'UploadId' => $uploadId];

        } catch (Exception $e) {
            // Don't abort on failure - allow resume later
            if (isset($fp) && is_resource($fp)) {
                fclose($fp);
            }

            // Mark as failed in database but keep upload ID for potential resume
            if ($backupId) {
                $this->markMultipartFailed($uploadId, $e->getMessage());
            }

            // Only abort if explicitly requested or after multiple failures
            if ($params['AbortOnFailure'] ?? false) {
                $this->abortMultipartUpload($bucket, $key, $uploadId);
            }

            throw new Exception("Multipart upload failed (can be resumed): " . $e->getMessage());
        }
    }

    /**
     * Find existing multipart upload for resume
     */
    private function findExistingUpload($backupId, $bucket, $key) {
        // This would query the multipart_uploads table
        // For now, return null (no existing upload)
        // In production, this would check the database
        return null;
    }

    /**
     * Track multipart upload in database
     */
    private function trackMultipartUpload($backupId, $uploadId, $bucket, $key, $filePath, $fileSize, $chunkSize, $totalParts) {
        // This would insert into multipart_uploads table
        // Implementation depends on database access in this class
        error_log("Tracking multipart upload: {$uploadId} for backup {$backupId}");
    }

    /**
     * Update multipart upload progress
     */
    private function updateMultipartProgress($uploadId, $parts) {
        // This would update the completed_parts JSON in database
        // Implementation depends on database access in this class
        error_log("Updated multipart progress: " . count($parts) . " parts completed");
    }

    /**
     * Mark multipart upload as complete
     */
    private function markMultipartComplete($uploadId) {
        // This would update status to 'completed' in database
        error_log("Marked multipart upload {$uploadId} as completed");
    }

    /**
     * Mark multipart upload as failed
     */
    private function markMultipartFailed($uploadId, $error) {
        // This would update status to 'failed' in database
        error_log("Marked multipart upload {$uploadId} as failed: {$error}");
    }

    private function calculateOptimalChunkSize($fileSize) {
        // Minimum chunk size: 5MB (AWS requirement)
        $minChunkSize = 5 * 1024 * 1024;

        // Maximum chunk size: 100MB (balance between memory usage and performance)
        $maxChunkSize = 100 * 1024 * 1024;

        // Target around 100-1000 parts for optimal performance
        $targetChunkSize = ceil($fileSize / 500);

        // Ensure chunk size is within bounds
        $chunkSize = max($minChunkSize, min($maxChunkSize, $targetChunkSize));

        // Round to nearest MB for cleaner logging
        return round($chunkSize / (1024 * 1024)) * 1024 * 1024;
    }

    private function initiateMultipartUpload($bucket, $key) {
        $url = $this->buildUrl($bucket, $key) . '?uploads=';
        // Use empty payload hash for multipart initiate (required by AWS spec)
        $emptyPayloadHash = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
        // Don't send Content-Length: 0 - Cloudflare R2 edge rejects it (returns HTML 400)
        // Omitting Content-Length works for both R2 and Versity S3
        $headers = $this->buildHeaders('POST', $bucket, $key, 'uploads=', 'application/octet-stream', $emptyPayloadHash, null);

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        curl_close($ch);

        if ($httpCode !== 200) {
            $errorDetails = "HTTP $httpCode";
            if ($curlError) {
                $errorDetails .= ", CURL: $curlError";
            }
            if ($response) {
                $errorDetails .= ", Response: " . substr($response, 0, 200);
            }
            error_log("Multipart upload initiation failed: {$errorDetails}");
            throw new Exception("Failed to initiate multipart upload: {$errorDetails}");
        }

        // Parse upload ID from XML response
        $xml = simplexml_load_string($response);
        if (!$xml || !isset($xml->UploadId)) {
            throw new Exception("Failed to parse upload ID from response");
        }

        return (string)$xml->UploadId;
    }

    private function uploadPart($bucket, $key, $uploadId, $partNumber, $data) {
        $url = $this->buildUrl($bucket, $key) . '?partNumber=' . $partNumber . '&uploadId=' . $uploadId;
        $queryString = 'partNumber=' . $partNumber . '&uploadId=' . $uploadId;

        // Calculate MD5 hash for content verification
        $md5 = base64_encode(md5($data, true));

        // Calculate SHA256 hash for part data (required for VersityGW)
        $payloadHash = hash('sha256', $data);

        $headers = $this->buildHeaders('PUT', $bucket, $key, $queryString, 'application/octet-stream', $payloadHash, strlen($data));
        $headers[] = 'Content-MD5: ' . $md5;

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
        curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 300); // 5 minutes per part
        curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1024);
        curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 30);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            error_log("Part upload failed for part {$partNumber}: HTTP {$httpCode}, Response: " . substr($response, 0, 500));
            throw new Exception("Failed to upload part {$partNumber}: HTTP $httpCode");
        }

        // Extract ETag from response headers - try multiple formats
        if (preg_match('/ETag:\s*"([^"]+)"/', $response, $matches)) {
            return $matches[1];
        } elseif (preg_match('/ETag:\s*([a-f0-9]{32})/', $response, $matches)) {
            return $matches[1];
        } elseif (preg_match('/etag:\s*"([^"]+)"/', $response, $matches)) {
            return $matches[1];
        } elseif (preg_match('/etag:\s*([a-f0-9]{32})/', $response, $matches)) {
            return $matches[1];
        } else {
            error_log("Failed to extract ETag for part {$partNumber}. Response headers: " . substr($response, 0, 1000));
            throw new Exception("Failed to extract ETag for part {$partNumber}");
        }
    }

    private function completeMultipartUpload($bucket, $key, $uploadId, $parts) {
        $url = $this->buildUrl($bucket, $key) . '?uploadId=' . $uploadId;
        $queryString = 'uploadId=' . $uploadId;

        // Build XML body with parts list
        $xml = '<?xml version="1.0" encoding="UTF-8"?>';
        $xml .= '<CompleteMultipartUpload>';
        foreach ($parts as $part) {
            $xml .= '<Part>';
            $xml .= '<PartNumber>' . $part['PartNumber'] . '</PartNumber>';
            $xml .= '<ETag>"' . $part['ETag'] . '"</ETag>';
            $xml .= '</Part>';
        }
        $xml .= '</CompleteMultipartUpload>';

        // Calculate SHA256 hash of the XML payload (required for VersityGW)
        $payloadHash = hash('sha256', $xml);

        $headers = $this->buildHeaders('POST', $bucket, $key, $queryString, 'text/xml', $payloadHash, strlen($xml));

        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $xml);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 300);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($httpCode !== 200) {
            throw new Exception("Failed to complete multipart upload: HTTP $httpCode");
        }

        // Parse response
        $xml = simplexml_load_string($response);
        return ['ETag' => (string)($xml->ETag ?? '')];
    }

    private function abortMultipartUpload($bucket, $key, $uploadId) {
        try {
            $url = $this->buildUrl($bucket, $key) . '?uploadId=' . $uploadId;
            $queryString = 'uploadId=' . $uploadId;
            $headers = $this->buildHeaders('DELETE', $bucket, $key, $queryString);

            $ch = curl_init();
            curl_setopt($ch, CURLOPT_URL, $url);
            curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
            curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
            curl_setopt($ch, CURLOPT_TIMEOUT, 60);

            curl_exec($ch);
            curl_close($ch);

            error_log("Aborted multipart upload: {$uploadId}");
        } catch (Exception $e) {
            error_log("Failed to abort multipart upload: " . $e->getMessage());
        }
    }

    public function putObject($params) {
        $url = $this->buildUrl($params['Bucket'], $params['Key']);

        // For S3, we can use UNSIGNED-PAYLOAD for streaming uploads
        // This avoids having to load the entire file into memory
        $payloadHash = 'UNSIGNED-PAYLOAD';

        $fileSize = filesize($params['SourceFile']);
        $headers = $this->buildHeaders('PUT', $params['Bucket'], $params['Key'], '', 'application/octet-stream', $payloadHash, $fileSize);
        
        $fp = fopen($params['SourceFile'], 'r');
        if (!$fp) {
            throw new Exception("Could not open file: " . $params['SourceFile']);
        }
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_PUT, true);
        curl_setopt($ch, CURLOPT_INFILE, $fp);
        curl_setopt($ch, CURLOPT_INFILESIZE, filesize($params['SourceFile']));
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);

        // Set timeouts to prevent hanging uploads
        // Connection timeout: 30 seconds to establish connection
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        // Transfer timeout: 30 minutes for large file uploads
        curl_setopt($ch, CURLOPT_TIMEOUT, 1800);
        // Low speed limit: 1 KB/s for at least 30 seconds = timeout
        curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1024);
        curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 30);

        // Add error handling for curl errors
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $curlError = curl_error($ch);
        $curlErrno = curl_errno($ch);
        $uploadTime = curl_getinfo($ch, CURLINFO_TOTAL_TIME);
        $uploadSpeed = curl_getinfo($ch, CURLINFO_SPEED_UPLOAD);
        curl_close($ch);
        fclose($fp);

        // Log upload details for debugging
        error_log("S3 Upload - File: {$params['SourceFile']}, Time: {$uploadTime}s, Speed: " . number_format($uploadSpeed/1024, 2) . " KB/s");

        if ($curlError) {
            $errorMsg = "CURL Error: " . $curlError;
            if ($curlErrno == CURLE_OPERATION_TIMEDOUT || $curlErrno == 28) {
                $errorMsg .= " (Upload timed out after {$uploadTime} seconds)";
            }
            error_log("S3 Upload Failed - " . $errorMsg);
            throw new Exception($errorMsg);
        }
        
        if ($httpCode >= 400) {
            // Try to extract error message from response
            $errorMsg = "Upload failed with HTTP code: $httpCode";
            if (!empty($response)) {
                // Try to parse XML error response
                $xml = @simplexml_load_string($response);
                if ($xml && isset($xml->Message)) {
                    $errorMsg .= " - " . (string)$xml->Message;
                } else {
                    $errorMsg .= " - Response: " . substr($response, 0, 500);
                }
            }
            throw new Exception($errorMsg);
        }
        
        // Extract ETag from response headers
        preg_match('/ETag: "([^"]+)"/', $response, $matches);
        $etag = $matches[1] ?? null;
        
        return ['ETag' => $etag];
    }
    
    public function getObject($params) {
        $url = $this->buildUrl($params['Bucket'], $params['Key']);
        $headers = $this->buildHeaders('GET', $params['Bucket'], $params['Key'], '');
        
        $fp = fopen($params['SaveAs'], 'w');
        if (!$fp) {
            throw new Exception("Could not create file: " . $params['SaveAs']);
        }
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_FILE, $fp);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

        // Set timeouts for downloads
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 1800);
        curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1024);
        curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 30);
        
        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        fclose($fp);
        
        if ($httpCode >= 400) {
            unlink($params['SaveAs']);
            throw new Exception("Download failed with HTTP code: $httpCode");
        }
        
        return ['success' => true];
    }
    
    public function streamObject($params) {
        $url = $this->buildUrl($params['Bucket'], $params['Key']);
        $headers = $this->buildHeaders('GET', $params['Bucket'], $params['Key'], '');
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_HEADER, false); // Don't include headers in output
        curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use ($params) {
            if (isset($params['Callback']) && is_callable($params['Callback'])) {
                $params['Callback']($data);
            } else {
                echo $data;
                flush(); // Ensure data is sent immediately
            }
            return strlen($data);
        });
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
        curl_setopt($ch, CURLOPT_BINARYTRANSFER, true); // Handle binary data properly

        // Set timeouts for streaming
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 1800);
        curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1024);
        curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, 30);
        
        $result = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $contentLength = curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception("Stream failed with HTTP code: $httpCode");
        }
        
        return ['success' => true, 'size' => $contentLength];
    }
    
    public function deleteObject($params) {
        $url = $this->buildUrl($params['Bucket'], $params['Key']);
        $headers = $this->buildHeaders('DELETE', $params['Bucket'], $params['Key'], '');
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception("Delete failed with HTTP code: $httpCode");
        }
        
        return ['success' => true];
    }
    
    public function listObjects($params) {
        $url = $this->buildUrl($params['Bucket'], '');
        $query = [];
        
        if (isset($params['Prefix'])) {
            $query['prefix'] = $params['Prefix'];
        }
        if (isset($params['MaxKeys'])) {
            $query['max-keys'] = $params['MaxKeys'];
        }
        
        if (!empty($query)) {
            $url .= '?' . http_build_query($query);
        }
        
        $queryString = '';
        if (!empty($query)) {
            $queryString = http_build_query($query);
        }
        
        $headers = $this->buildHeaders('GET', $params['Bucket'], '', $queryString);
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

        // Set timeouts for list operations
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 60);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception("List failed with HTTP code: $httpCode");
        }
        
        // Parse XML response
        $xml = simplexml_load_string($response);
        $contents = [];
        
        if ($xml && isset($xml->Contents)) {
            foreach ($xml->Contents as $object) {
                $contents[] = [
                    'Key' => (string)$object->Key,
                    'Size' => (int)$object->Size,
                    'LastModified' => (string)$object->LastModified
                ];
            }
        }
        
        return ['Contents' => $contents];
    }
    
    public function headObject($params) {
        $url = $this->buildUrl($params['Bucket'], $params['Key']);
        $headers = $this->buildHeaders('HEAD', $params['Bucket'], $params['Key'], '');
        
        $ch = curl_init();
        curl_setopt($ch, CURLOPT_URL, $url);
        curl_setopt($ch, CURLOPT_NOBODY, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);

        // Set timeouts for HEAD requests
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
        curl_setopt($ch, CURLOPT_TIMEOUT, 30);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode >= 400) {
            throw new Exception("Head request failed with HTTP code: $httpCode");
        }
        
        // Parse headers
        $headers = [];
        $lines = explode("\n", $response);
        foreach ($lines as $line) {
            if (strpos($line, ':') !== false) {
                list($key, $value) = explode(':', $line, 2);
                $headers[trim($key)] = trim($value);
            }
        }
        
        return [
            'ContentLength' => $headers['Content-Length'] ?? 0,
            'LastModified' => $headers['Last-Modified'] ?? '',
            'ETag' => trim($headers['ETag'] ?? '', '"')
        ];
    }
    
    private function buildUrl($bucket, $key) {
        $endpoint = rtrim($this->config['endpoint'], '/');
        
        // Handle different S3 service URL formats
        // Path-style: https://s3.amazonaws.com/bucket/key
        // Virtual-hosted-style: https://bucket.s3.amazonaws.com/key
        
        // For now, use path-style which works with most S3-compatible services
        $url = $endpoint . '/' . $bucket;
        if (!empty($key)) {
            $url .= '/' . ltrim($key, '/');
        }
        
        return $url;
    }
    
    private function buildHeadersMinimal($method, $bucket, $key, $queryString = '', $payloadHash = 'UNSIGNED-PAYLOAD') {
        $timestamp = gmdate('Ymd\THis\Z');
        $dateStamp = gmdate('Ymd');

        // AWS Signature Version 4
        $region = $this->config['region'] ?? 'us-east-1';
        $service = 's3';
        $credentialScope = $dateStamp . '/' . $region . '/' . $service . '/aws4_request';

        $host = parse_url($this->config['endpoint'], PHP_URL_HOST);
        $port = parse_url($this->config['endpoint'], PHP_URL_PORT);

        // Include port in host header if it's not standard (80/443)
        if ($port && $port != 80 && $port != 443) {
            $host = $host . ':' . $port;
        }

        $headers = [
            'Host: ' . $host,
            'X-Amz-Date: ' . $timestamp,
            'X-Amz-Content-Sha256: ' . $payloadHash
        ];

        $headers[] = 'Authorization: ' . $this->signRequestV4Minimal($method, $bucket, $key, $timestamp, $dateStamp, $region, $service, $credentialScope, $queryString, $payloadHash);

        return $headers;
    }

    private function buildHeaders($method, $bucket, $key, $queryString = '', $contentType = 'application/octet-stream', $payloadHash = 'UNSIGNED-PAYLOAD', $contentLength = null) {
        $timestamp = gmdate('Ymd\THis\Z');
        $dateStamp = gmdate('Ymd');
        
        // AWS Signature Version 4
        $region = $this->config['region'] ?? 'us-east-1';
        $service = 's3';
        $credentialScope = $dateStamp . '/' . $region . '/' . $service . '/aws4_request';
        
        $host = parse_url($this->config['endpoint'], PHP_URL_HOST);
        $port = parse_url($this->config['endpoint'], PHP_URL_PORT);
        
        // Include port in host header if it's not standard (80/443)
        if ($port && $port != 80 && $port != 443) {
            $host = $host . ':' . $port;
        }
        
        $headers = [
            'Host: ' . $host,
            'X-Amz-Date: ' . $timestamp,
            'X-Amz-Content-Sha256: ' . $payloadHash,
            'Content-Type: ' . $contentType
        ];
        
        // Add Content-Length for PUT requests
        if ($contentLength !== null) {
            $headers[] = 'Content-Length: ' . $contentLength;
        }
        
        $headers[] = 'Authorization: ' . $this->signRequestV4($method, $bucket, $key, $timestamp, $dateStamp, $region, $service, $credentialScope, $queryString, $contentType, $payloadHash, $contentLength);
        
        return $headers;
    }
    
    private function signRequestV4Minimal($method, $bucket, $key, $timestamp, $dateStamp, $region, $service, $credentialScope, $queryString = '', $payloadHash = 'UNSIGNED-PAYLOAD') {
        // AWS Signature Version 4 - Minimal version for initiate multipart
        $accessKey = $this->config['access_key'];
        $secretKey = $this->config['secret_key'];

        // Create canonical URI
        $parts = [$bucket];
        if (!empty($key)) {
            $keyParts = explode('/', $key);
            foreach ($keyParts as $part) {
                if ($part !== '') {
                    $parts[] = rawurlencode($part);
                }
            }
        }
        $canonicalUri = '/' . implode('/', $parts);

        $canonicalQueryString = $queryString;

        // Create canonical headers - minimal set
        $host = parse_url($this->config['endpoint'], PHP_URL_HOST);
        $port = parse_url($this->config['endpoint'], PHP_URL_PORT);

        if ($port && $port != 80 && $port != 443) {
            $host = $host . ':' . $port;
        }

        $canonicalHeaders = "host:" . $host . "\n" .
                           "x-amz-content-sha256:" . $payloadHash . "\n" .
                           "x-amz-date:" . $timestamp . "\n";
        $signedHeaders = 'host;x-amz-content-sha256;x-amz-date';

        $canonicalRequest = $method . "\n" .
                           $canonicalUri . "\n" .
                           $canonicalQueryString . "\n" .
                           $canonicalHeaders . "\n" .
                           $signedHeaders . "\n" .
                           $payloadHash;

        // Create string to sign
        $algorithm = 'AWS4-HMAC-SHA256';
        $stringToSign = $algorithm . "\n" .
                       $timestamp . "\n" .
                       $credentialScope . "\n" .
                       hash('sha256', $canonicalRequest);

        // Create signing key
        $signingKey = $this->getSigningKey($secretKey, $dateStamp, $region, $service);

        // Create signature
        $signature = hash_hmac('sha256', $stringToSign, $signingKey);

        // Create authorization header
        $authorization = $algorithm . ' ' .
                        'Credential=' . $accessKey . '/' . $credentialScope . ', ' .
                        'SignedHeaders=' . $signedHeaders . ', ' .
                        'Signature=' . $signature;

        return $authorization;
    }

    private function signRequestV4($method, $bucket, $key, $timestamp, $dateStamp, $region, $service, $credentialScope, $queryString = '', $contentType = 'application/octet-stream', $payloadHash = 'UNSIGNED-PAYLOAD', $contentLength = null) {
        // AWS Signature Version 4
        $accessKey = $this->config['access_key'];
        $secretKey = $this->config['secret_key'];
        
        // Create canonical URI - must be URL encoded
        // For S3, the canonical URI should include the bucket and the object key
        // The key should be URL-encoded, but keep the slashes
        $parts = [$bucket];
        if (!empty($key)) {
            $keyParts = explode('/', $key);
            foreach ($keyParts as $part) {
                if ($part !== '') {
                    $parts[] = rawurlencode($part);
                }
            }
        }
        $canonicalUri = '/' . implode('/', $parts);
        
        $canonicalQueryString = $queryString;
        
        // Create canonical headers - must be lowercase and sorted
        $host = parse_url($this->config['endpoint'], PHP_URL_HOST);
        $port = parse_url($this->config['endpoint'], PHP_URL_PORT);

        // Include port in host header if it's not standard (80/443)
        if ($port && $port != 80 && $port != 443) {
            $host = $host . ':' . $port;
        }

        // Build canonical headers - MUST include all headers being sent
        // Headers must be lowercase and sorted alphabetically
        $canonicalHeaders = "";
        $signedHeadersList = [];

        // Add content-length if provided (alphabetically before content-type)
        if ($contentLength !== null) {
            $canonicalHeaders .= "content-length:" . $contentLength . "\n";
            $signedHeadersList[] = 'content-length';
        }

        $canonicalHeaders .= "content-type:" . $contentType . "\n";
        $signedHeadersList[] = 'content-type';

        $canonicalHeaders .= "host:" . $host . "\n";
        $signedHeadersList[] = 'host';

        $canonicalHeaders .= "x-amz-content-sha256:" . $payloadHash . "\n";
        $signedHeadersList[] = 'x-amz-content-sha256';

        $canonicalHeaders .= "x-amz-date:" . $timestamp . "\n";
        $signedHeadersList[] = 'x-amz-date';

        $signedHeaders = implode(';', $signedHeadersList);
        
        $canonicalRequest = $method . "\n" .
                           $canonicalUri . "\n" .
                           $canonicalQueryString . "\n" .
                           $canonicalHeaders . "\n" .
                           $signedHeaders . "\n" .
                           $payloadHash;
        
        // Create string to sign
        $algorithm = 'AWS4-HMAC-SHA256';
        $stringToSign = $algorithm . "\n" .
                       $timestamp . "\n" .
                       $credentialScope . "\n" .
                       hash('sha256', $canonicalRequest);
        
        
        // Create signing key
        $signingKey = $this->getSigningKey($secretKey, $dateStamp, $region, $service);
        
        // Create signature
        $signature = hash_hmac('sha256', $stringToSign, $signingKey);
        
        // Create authorization header
        $authorization = $algorithm . ' ' .
                        'Credential=' . $accessKey . '/' . $credentialScope . ', ' .
                        'SignedHeaders=' . $signedHeaders . ', ' .
                        'Signature=' . $signature;
        
        
        return $authorization;
    }
    
    private function getSigningKey($secretKey, $dateStamp, $region, $service) {
        $kDate = hash_hmac('sha256', $dateStamp, 'AWS4' . $secretKey, true);
        $kRegion = hash_hmac('sha256', $region, $kDate, true);
        $kService = hash_hmac('sha256', $service, $kRegion, true);
        $kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
        
        return $kSigning;
    }
}