<?php
/**
 * Wrenio API Class
 *
 * Handles all Cloudflare R2 API operations: upload, delete, copy, and
 * AWS Signature V4 authentication.
 *
 * Key generation change in v4: uses _wp_attached_file (stable path) instead
 * of post_date (volatile). Existing attachments may have drifted keys —
 * use the Reconcile tool to align them.
 *
 * @package Wrenio_Media_Offload
 * @since 1.0.0
 */

namespace Wrenio_Media_Offload;

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

require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-wrenio-logger.php';

/**
 * Class Wrenio_API
 */
class Wrenio_API {

    /** Maximum file size for in-memory upload (50 MB). */
    const MAX_UPLOAD_SIZE = 52428800;

    /** Allowed MIME types for upload. */
    const ALLOWED_MIME_TYPES = array(
        'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
        'image/bmp', 'image/tiff', 'image/avif',
        'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/mpeg',
        'video/webm', 'video/ogg',
        'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/x-m4a', 'audio/flac', 'audio/aac',
        'application/pdf',
    );

    /** @var array */
    private $settings;

    /** @var Wrenio_Logger */
    private $logger;

    /**
     * Constructor
     *
     * @param array $settings Plugin settings
     */
    public function __construct( $settings ) {
        // wp-config.php constants take priority over DB-stored values
        if ( defined( 'WRENIO_R2_ACCOUNT_ID' ) && WRENIO_R2_ACCOUNT_ID ) {
            $settings['account_id'] = WRENIO_R2_ACCOUNT_ID;
        }
        if ( defined( 'WRENIO_R2_ACCESS_KEY_ID' ) && WRENIO_R2_ACCESS_KEY_ID ) {
            $settings['access_key_id'] = WRENIO_R2_ACCESS_KEY_ID;
        }
        if ( defined( 'WRENIO_R2_SECRET_ACCESS_KEY' ) && WRENIO_R2_SECRET_ACCESS_KEY ) {
            $settings['secret_access_key'] = WRENIO_R2_SECRET_ACCESS_KEY;
        }
        if ( defined( 'WRENIO_R2_BUCKET_NAME' ) && WRENIO_R2_BUCKET_NAME ) {
            $settings['bucket_name'] = WRENIO_R2_BUCKET_NAME;
        }
        if ( defined( 'WRENIO_R2_PUBLIC_URL' ) && WRENIO_R2_PUBLIC_URL ) {
            $settings['public_url'] = WRENIO_R2_PUBLIC_URL;
        }

        $this->settings = $settings;
        $this->logger   = Wrenio_Logger::get_instance();
    }

    /**
     * Check if R2 is configured.
     */
    public function is_configured() {
        return ! empty( $this->settings['account_id'] )
            && ! empty( $this->settings['access_key_id'] )
            && ! empty( $this->settings['secret_access_key'] )
            && ! empty( $this->settings['bucket_name'] );
    }

    /**
     * Get R2 endpoint URL.
     */
    public function get_endpoint() {
        if ( empty( $this->settings['account_id'] ) ) {
            return '';
        }
        return sprintf( 'https://%s.r2.cloudflarestorage.com', rawurlencode( $this->settings['account_id'] ) );
    }

    /**
     * Get bucket name.
     */
    public function get_bucket() {
        return $this->settings['bucket_name'] ?? '';
    }

    /**
     * Test R2 connection by performing a HEAD request on the bucket.
     *
     * A proper test_put() method also exists for verifying write permissions.
     *
     * @return true|\WP_Error
     */
    public function test_connection() {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 credentials not configured.', 'wrenio-media-offload' ) );
        }

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $url      = $endpoint . '/' . rawurlencode( $bucket ) . '/';

        $response = wp_remote_head( $url, array(
            'headers'    => $this->get_auth_headers( 'HEAD', '/' . $bucket . '/', '' ),
            'timeout'    => 15,
            'sslverify'  => true,
            'user-agent' => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
        ) );

        if ( is_wp_error( $response ) ) {
            $this->logger->error( 'R2 Test - Connection error: ' . $response->get_error_message() );
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $this->logger->debug( 'R2 Test - Response code: ' . $code );

        // 200 or 403 (no list permission) both indicate reachable bucket
        if ( $code === 200 || $code === 403 ) {
            return true;
        }

        return new \WP_Error( 'connection_failed', sprintf(
            /* translators: %d: HTTP status code */
            __( 'Connection failed with HTTP %d', 'wrenio-media-offload' ),
            $code
        ) );
    }

    /**
     * Test write permissions by PUT + DELETE of a tiny object.
     *
     * More reliable than test_connection() for catching token-scope issues,
     * because Cloudflare can return 403 on HEAD even when PUT is allowed.
     *
     * @return true|\WP_Error
     */
    public function test_put() {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 credentials not configured.', 'wrenio-media-offload' ) );
        }

        $test_key  = 'wrenio-media-offload-healthcheck/' . wp_generate_uuid4() . '.txt';
        $test_body = 'healthcheck';
        $endpoint  = $this->get_endpoint();
        $bucket    = $this->settings['bucket_name'];
        $url       = $endpoint . '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $test_key );
        $path      = '/' . $bucket . '/' . $test_key;

        $headers = $this->get_auth_headers( 'PUT', $path, $test_body, 'text/plain' );
        $headers['Content-Length'] = (string) strlen( $test_body );

        $response = wp_remote_request( $url, array(
            'method'      => 'PUT',
            'headers'     => $headers,
            'body'        => $test_body,
            'timeout'     => 15,
            'sslverify'   => true,
            'user-agent'  => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
            'httpversion' => '1.1',
        ) );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code !== 200 ) {
            $body = wp_remote_retrieve_body( $response );
            return new \WP_Error( 'put_failed', sprintf(
                /* translators: 1: HTTP status code, 2: error body */
                __( 'Write test failed with HTTP %1$d: %2$s', 'wrenio-media-offload' ),
                $code,
                $this->parse_r2_error_message( $body )
            ) );
        }

        // Clean up the test object
        $this->delete_file( $test_key );

        return true;
    }

    /**
     * Upload a file to R2.
     *
     * @param int    $attachment_id Attachment ID
     * @param string $file_path     Local file path
     * @param string $size          Image size name
     * @return array|\WP_Error Array with key, url, size on success
     */
    public function upload_file( $attachment_id, $file_path, $size = 'full' ) {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        if ( ! file_exists( $file_path ) ) {
            $this->logger->error( 'R2 Upload - File not found: ' . $file_path );
            return new \WP_Error( 'file_not_found', __( 'The requested file could not be found on the server.', 'wrenio-media-offload' ) );
        }

        // Guard against loading very large files into memory
        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
        $file_size = @filesize( $file_path );
        if ( $file_size !== false && $file_size > self::MAX_UPLOAD_SIZE ) {
            $size_mb = round( $file_size / 1048576, 1 );
            $max_mb  = round( self::MAX_UPLOAD_SIZE / 1048576 );
            $this->logger->error( "R2 Upload - File too large ({$size_mb} MB, max {$max_mb} MB): {$file_path}" );
            return new \WP_Error(
                'file_too_large',
                sprintf(
                    /* translators: 1: file size in MB, 2: max size in MB */
                    __( 'File is too large for upload (%1$s MB). Maximum supported size is %2$s MB.', 'wrenio-media-offload' ),
                    $size_mb,
                    $max_mb
                )
            );
        }

        $filename = basename( $file_path );

        // Validate file type against an allowlist of media MIME types
        $filetype = wp_check_filetype( $filename );
        if ( empty( $filetype['type'] ) || ! in_array( $filetype['type'], self::ALLOWED_MIME_TYPES, true ) ) {
            $type_label = $filetype['type'] ?: 'unknown';
            $this->logger->warning( 'R2 Upload - Blocked disallowed file type: ' . $type_label . ' for ' . $filename );
            return new \WP_Error( 'disallowed_type', __( 'This file type is not allowed for R2 upload.', 'wrenio-media-offload' ) );
        }

        $mime_type = $filetype['type'];
        $key       = $this->generate_object_key( $attachment_id, $filename, $size );

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $url      = $endpoint . '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $key );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $file_content = file_get_contents( $file_path );
        if ( $file_content === false ) {
            return new \WP_Error( 'file_read_error', __( 'Cannot read file', 'wrenio-media-offload' ) );
        }

        $path = '/' . $bucket . '/' . $key;

        $headers = $this->get_auth_headers( 'PUT', $path, $file_content, $mime_type );
        $headers['Content-Length'] = (string) strlen( $file_content );

        $response = wp_remote_request( $url, array(
            'method'      => 'PUT',
            'headers'     => $headers,
            'body'        => $file_content,
            'timeout'     => 60,
            'sslverify'   => true,
            'user-agent'  => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
            'blocking'    => true,
            'httpversion' => '1.1',
        ) );

        if ( is_wp_error( $response ) ) {
            $this->logger->error( 'R2 Upload - Upload error: ' . $response->get_error_message() );
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $this->logger->debug( 'R2 Upload - Response code: ' . $code );

        if ( $code === 200 ) {
            $public_url = $this->get_public_url( $key );
            $this->logger->info( 'R2 Upload - Successful: ' . $public_url );
            return array(
                'key'  => $key,
                'url'  => $public_url,
                'size' => strlen( $file_content ),
            );
        }

        $error_body    = wp_remote_retrieve_body( $response );
        $error_message = sprintf( 'HTTP %d: %s', $code, $this->parse_r2_error_message( $error_body ) );
        $this->logger->error( 'R2 Upload - Failed: ' . $error_message );

        return new \WP_Error( 'upload_failed', $error_message );
    }

    /**
     * Upload all image sizes, respecting upload_mode.
     *
     * @param int   $attachment_id   Attachment ID
     * @param array $files_to_upload Array of size => file_path
     * @return bool
     */
    public function upload_all_sizes( $attachment_id, $files_to_upload ) {
        $upload_mode = $this->settings['upload_mode'] ?? 'full_only';

        if ( $upload_mode === 'full_only' ) {
            $files_to_upload = isset( $files_to_upload['full'] ) ? array( 'full' => $files_to_upload['full'] ) : array();
        }

        $uploaded_files = array();
        $upload_success = true;

        foreach ( $files_to_upload as $size => $file ) {
            $result = $this->upload_file( $attachment_id, $file, $size );

            if ( is_wp_error( $result ) ) {
                if ( $size === 'full' ) {
                    $upload_success = false;
                }
                // Non-full size failures don't fail the overall upload
                continue;
            }

            if ( $size === 'full' ) {
                update_post_meta( $attachment_id, '_r2_url', $result['url'] );
                update_post_meta( $attachment_id, '_r2_key', $result['key'] );
            } else {
                update_post_meta( $attachment_id, '_r2_url_' . $size, $result['url'] );
                update_post_meta( $attachment_id, '_r2_key_' . $size, $result['key'] );
            }

            $uploaded_files[ $size ] = array(
                'url' => $result['url'],
                'key' => $result['key'],
            );
        }

        if ( ! empty( $uploaded_files ) ) {
            update_post_meta( $attachment_id, '_r2_uploaded_files', $uploaded_files );
        }

        // Delete local files after successful upload, if enabled
        if ( $upload_success && ! empty( $this->settings['delete_local_files'] ) ) {
            foreach ( $uploaded_files as $size => $data ) {
                $file = $files_to_upload[ $size ] ?? null;
                if ( $file && file_exists( $file ) ) {
                    wp_delete_file( $file );
                }
            }
            update_post_meta( $attachment_id, '_r2_local_deleted', true );
        }

        return $upload_success;
    }

    /**
     * Delete object from R2.
     *
     * @param string $key Object key
     * @return true|\WP_Error
     */
    public function delete_file( $key ) {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        if ( empty( $key ) ) {
            return new \WP_Error( 'empty_key', __( 'Empty object key', 'wrenio-media-offload' ) );
        }

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $url      = $endpoint . '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $key );
        $path     = '/' . $bucket . '/' . $key;

        $response = wp_remote_request( $url, array(
            'method'     => 'DELETE',
            'headers'    => $this->get_auth_headers( 'DELETE', $path, '' ),
            'timeout'    => 30,
            'sslverify'  => true,
            'user-agent' => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
        ) );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code === 204 || $code === 404 ) {
            return true;
        }

        $body = wp_remote_retrieve_body( $response );
        return new \WP_Error( 'delete_failed', sprintf( 'HTTP %d: %s', $code, $this->parse_r2_error_message( $body ) ) );
    }

    /**
     * Check whether an R2 object exists.
     *
     * @param string $key Object key
     * @return bool|\WP_Error
     */
    public function object_exists( $key ) {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $url      = $endpoint . '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $key );
        $path     = '/' . $bucket . '/' . $key;

        $response = wp_remote_head( $url, array(
            'headers'    => $this->get_auth_headers( 'HEAD', $path, '' ),
            'timeout'    => 15,
            'sslverify'  => true,
            'user-agent' => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
        ) );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code === 200 ) {
            return true;
        }
        if ( $code === 404 ) {
            return false;
        }

        return new \WP_Error( 'head_failed', sprintf( 'HTTP %d', $code ) );
    }

    /**
     * Server-side copy of an R2 object from source_key to dest_key.
     *
     * Used by the reconciliation engine to move objects to their correct
     * paths without re-uploading from the WordPress server.
     *
     * @param string $source_key Current object key
     * @param string $dest_key   New object key
     * @return true|\WP_Error
     */
    public function copy_object( $source_key, $dest_key ) {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        if ( empty( $source_key ) || empty( $dest_key ) ) {
            return new \WP_Error( 'empty_key', __( 'Source and destination keys must not be empty', 'wrenio-media-offload' ) );
        }

        if ( $source_key === $dest_key ) {
            return true; // Nothing to do
        }

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $url      = $endpoint . '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $dest_key );
        $path     = '/' . $bucket . '/' . $dest_key;

        // x-amz-copy-source must be URL-encoded path
        $copy_source = '/' . rawurlencode( $bucket ) . '/' . $this->encode_key_path( $source_key );

        $extra_headers = array(
            'x-amz-copy-source' => $copy_source,
        );

        $headers = $this->get_auth_headers( 'PUT', $path, '', '', $extra_headers );
        $headers['Content-Length'] = '0';

        $response = wp_remote_request( $url, array(
            'method'      => 'PUT',
            'headers'     => $headers,
            'body'        => '',
            'timeout'     => 60,
            'sslverify'   => true,
            'user-agent'  => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
            'httpversion' => '1.1',
        ) );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code !== 200 ) {
            $body = wp_remote_retrieve_body( $response );
            return new \WP_Error( 'copy_failed', sprintf( 'HTTP %d: %s', $code, $this->parse_r2_error_message( $body ) ) );
        }

        // CopyObject can return 200 with an error body. Verify the XML indicates success.
        $body = wp_remote_retrieve_body( $response );
        if ( strpos( $body, '<Error>' ) !== false ) {
            return new \WP_Error( 'copy_failed', sprintf( 'CopyObject error: %s', $this->parse_r2_error_message( $body ) ) );
        }

        return true;
    }

    /**
     * List objects in the bucket (v4.1 — NEW, for orphan cleanup).
     *
     * Uses the ListObjectsV2 API. Returns up to 1000 keys per call along
     * with a continuation token for pagination. Caller is responsible for
     * paginating through all pages.
     *
     * @param string $prefix             Optional prefix filter (e.g. '2024/01/')
     * @param string $continuation_token Optional pagination token from prior call
     * @param int    $max_keys           Max keys per response (1-1000)
     * @return array|\WP_Error {
     *     @type string[] $keys         Object keys returned in this page
     *     @type string   $next_token   Continuation token for next page, or '' if complete
     *     @type bool     $is_truncated True if more pages are available
     * }
     */
    public function list_objects( $prefix = '', $continuation_token = '', $max_keys = 1000 ) {
        if ( ! $this->is_configured() ) {
            return new \WP_Error( 'not_configured', __( 'R2 not configured', 'wrenio-media-offload' ) );
        }

        $max_keys = max( 1, min( 1000, (int) $max_keys ) );

        $endpoint = $this->get_endpoint();
        $bucket   = $this->settings['bucket_name'];
        $path     = '/' . $bucket;

        // Build query parameters. ListObjectsV2 is selected via list-type=2.
        $params = array(
            'list-type' => '2',
            'max-keys'  => (string) $max_keys,
        );
        if ( $prefix !== '' ) {
            $params['prefix'] = $prefix;
        }
        if ( $continuation_token !== '' ) {
            $params['continuation-token'] = $continuation_token;
        }

        // Construct the raw (unsorted) query string. The signer canonicalises it.
        $query_parts = array();
        foreach ( $params as $k => $v ) {
            $query_parts[] = rawurlencode( $k ) . '=' . rawurlencode( $v );
        }
        $query_string = implode( '&', $query_parts );

        $url = $endpoint . '/' . rawurlencode( $bucket ) . '?' . $query_string;

        $response = wp_remote_get( $url, array(
            'headers'    => $this->get_auth_headers( 'GET', $path, '', '', array(), $query_string ),
            'timeout'    => 30,
            'sslverify'  => true,
            'user-agent' => WRENIO_MEDIA_OFFLOAD_USER_AGENT,
        ) );

        if ( is_wp_error( $response ) ) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code( $response );
        $body = wp_remote_retrieve_body( $response );

        if ( $code !== 200 ) {
            return new \WP_Error( 'list_failed', sprintf( 'HTTP %d: %s', $code, $this->parse_r2_error_message( $body ) ) );
        }

        // Parse the XML response. ListBucketResult contains zero or more Contents
        // blocks, each with a Key element.
        $previous = libxml_use_internal_errors( true );
        $xml      = simplexml_load_string( $body );
        libxml_use_internal_errors( $previous );

        if ( ! $xml ) {
            return new \WP_Error( 'list_parse_failed', 'Failed to parse ListObjectsV2 XML response' );
        }

        $keys = array();
        if ( isset( $xml->Contents ) ) {
            foreach ( $xml->Contents as $content ) {
                if ( isset( $content->Key ) ) {
                    $keys[] = (string) $content->Key;
                }
            }
        }

        $is_truncated = isset( $xml->IsTruncated ) && ( (string) $xml->IsTruncated === 'true' );
        $next_token   = '';
        if ( $is_truncated && isset( $xml->NextContinuationToken ) ) {
            $next_token = (string) $xml->NextContinuationToken;
        }

        return array(
            'keys'         => $keys,
            'next_token'   => $next_token,
            'is_truncated' => $is_truncated,
        );
    }

    /**
     * Generate R2 object key from stable attachment path.
     *
     * v4 change: uses _wp_attached_file (immutable per attachment) rather than
     * post_date (which can drift). This ensures R2 keys mirror the filesystem
     * layout on the origin server and remain consistent across post edits.
     *
     * @param int    $attachment_id Attachment ID
     * @param string $filename      File name (for the specific size being uploaded)
     * @param string $size          Image size
     * @return string
     */
    private function generate_object_key( $attachment_id, $filename, $size = 'full' ) {
        $attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true );

        if ( ! empty( $attached_file ) ) {
            $safe_filename = sanitize_file_name( $filename );

            if ( $size === 'full' ) {
                return $attached_file;
            }

            // Size variants live in the same directory as the full file
            $directory = dirname( $attached_file );
            return ( $directory === '.' || $directory === '' )
                ? $safe_filename
                : $directory . '/' . $safe_filename;
        }

        // Fallback: this only triggers for attachments with no _wp_attached_file
        // meta, which is unusual but possible during early upload hooks.
        $upload_date   = get_the_date( 'Y/m', $attachment_id );
        $safe_filename = sanitize_file_name( $filename );
        return $upload_date . '/' . $safe_filename;
    }

    /**
     * Get public URL for an R2 object.
     *
     * @param string $key Object key
     * @return string
     */
    public function get_public_url( $key ) {
        $encoded_path = $this->encode_key_path( $key );

        if ( ! empty( $this->settings['public_url'] ) ) {
            return rtrim( $this->settings['public_url'], '/' ) . '/' . $encoded_path;
        }

        return $this->get_endpoint() . '/' . rawurlencode( $this->settings['bucket_name'] ) . '/' . $encoded_path;
    }

    /**
     * URL-encode each path segment of a key, preserving slashes.
     *
     * @param string $key
     * @return string
     */
    private function encode_key_path( $key ) {
        $parts = explode( '/', $key );
        foreach ( $parts as &$part ) {
            $part = rawurlencode( $part );
        }
        return implode( '/', $parts );
    }

    /**
     * Parse XML error response from R2 into a human-readable message.
     *
     * Uses safe XML parsing: external entity loading is disabled via flags
     * to prevent XXE attacks even though the input is server-to-server.
     *
     * @param string $body Response body
     * @return string
     */
    private function parse_r2_error_message( $body ) {
        if ( empty( $body ) ) {
            return '';
        }
        if ( strpos( $body, '<?xml' ) !== 0 && strpos( $body, '<Error' ) === false ) {
            return substr( $body, 0, 500 );
        }

        $prev_setting = libxml_use_internal_errors( true );

        // LIBXML_NONET disables network access; LIBXML_NOENT is intentionally NOT used
        // (that would expand entities). This blocks XXE even if R2 response were malicious.
        $xml = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOCDATA );
        libxml_clear_errors();
        libxml_use_internal_errors( $prev_setting );

        if ( $xml === false ) {
            return substr( $body, 0, 500 );
        }

        $code_str    = isset( $xml->Code ) ? (string) $xml->Code : '';
        $message_str = isset( $xml->Message ) ? (string) $xml->Message : '';

        if ( $code_str === 'AccessDenied' ) {
            return __( 'Access Denied — check that your R2 API token has Object Read & Write permission and is scoped to this bucket.', 'wrenio-media-offload' );
        }

        if ( $code_str || $message_str ) {
            return trim( $code_str . ': ' . $message_str, ' :' );
        }

        return substr( $body, 0, 500 );
    }

    /**
     * Generate AWS Signature V4 headers.
     *
     * @param string $method        HTTP method
     * @param string $path          Canonical URI (path portion)
     * @param string $body          Request body
     * @param string $content_type  Content type (for PUT with body)
     * @param array  $extra_headers Extra headers to include in the signature
     * @return array Headers array
     */
    private function get_auth_headers( $method, $path, $body = '', $content_type = '', $extra_headers = array(), $query_string = '' ) {
        $service    = 's3';
        $region     = 'auto'; // R2 uses 'auto'
        $access_key = $this->settings['access_key_id'];
        $secret_key = $this->settings['secret_access_key'];
        $host       = wp_parse_url( $this->get_endpoint(), PHP_URL_HOST );

        // Canonical URI must be encoded per-segment. Sign the encoded form;
        // the HTTP request URL also encodes per-segment (see encode_key_path).
        $canonical_uri = '/';
        $segments      = explode( '/', ltrim( $path, '/' ) );
        $encoded_segs  = array();
        foreach ( $segments as $seg ) {
            $encoded_segs[] = rawurlencode( $seg );
        }
        $canonical_uri .= implode( '/', $encoded_segs );
        // Preserve trailing slash (for directory HEAD requests)
        if ( substr( $path, -1 ) === '/' && substr( $canonical_uri, -1 ) !== '/' ) {
            $canonical_uri .= '/';
        }

        $timestamp    = gmdate( 'Ymd\THis\Z' );
        $date         = gmdate( 'Ymd' );
        $payload_hash = hash( 'sha256', $body );

        $req_headers = array(
            'Host'                 => $host,
            'X-Amz-Date'           => $timestamp,
            'X-Amz-Content-Sha256' => $payload_hash,
        );

        if ( $method === 'PUT' && ! empty( $content_type ) ) {
            $req_headers['Content-Type'] = $content_type;
        }

        // Merge extra headers (e.g. x-amz-copy-source for CopyObject)
        foreach ( $extra_headers as $k => $v ) {
            $req_headers[ $k ] = $v;
        }

        // Lowercase and sort headers for canonical form
        $lower_headers = array();
        foreach ( $req_headers as $key => $value ) {
            $lower_headers[ strtolower( $key ) ] = $value;
        }
        ksort( $lower_headers, SORT_STRING );

        $canonical_headers  = '';
        $signed_headers_arr = array();
        foreach ( $lower_headers as $lower_key => $value ) {
            $signed_headers_arr[] = $lower_key;
            $canonical_headers   .= $lower_key . ':' . trim( $value ) . "\n";
        }

        $signed_headers = implode( ';', $signed_headers_arr );

        // Canonical query string: params must be URI-encoded and sorted by name.
        // $query_string comes in raw (e.g. "list-type=2&prefix=foo"); we re-canonicalise it.
        $canonical_query = '';
        if ( ! empty( $query_string ) ) {
            $pairs = array();
            foreach ( explode( '&', $query_string ) as $kv ) {
                if ( $kv === '' ) {
                    continue;
                }
                $parts = explode( '=', $kv, 2 );
                $k     = rawurlencode( rawurldecode( $parts[0] ) );
                $v     = isset( $parts[1] ) ? rawurlencode( rawurldecode( $parts[1] ) ) : '';
                $pairs[] = array( $k, $v );
            }
            usort( $pairs, function( $a, $b ) {
                return strcmp( $a[0], $b[0] );
            } );
            $out = array();
            foreach ( $pairs as $p ) {
                $out[] = $p[0] . '=' . $p[1];
            }
            $canonical_query = implode( '&', $out );
        }

        $canonical_request = implode( "\n", array(
            $method,
            $canonical_uri,
            $canonical_query,
            $canonical_headers,
            $signed_headers,
            $payload_hash,
        ) );

        $credential_scope = $date . '/' . $region . '/' . $service . '/aws4_request';
        $string_to_sign   = implode( "\n", array(
            'AWS4-HMAC-SHA256',
            $timestamp,
            $credential_scope,
            hash( 'sha256', $canonical_request ),
        ) );

        $date_key    = hash_hmac( 'sha256', $date, 'AWS4' . $secret_key, true );
        $region_key  = hash_hmac( 'sha256', $region, $date_key, true );
        $service_key = hash_hmac( 'sha256', $service, $region_key, true );
        $signing_key = hash_hmac( 'sha256', 'aws4_request', $service_key, true );

        $signature = hash_hmac( 'sha256', $string_to_sign, $signing_key );

        $req_headers['Authorization'] = sprintf(
            'AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s',
            $access_key,
            $credential_scope,
            $signed_headers,
            $signature
        );

        return $req_headers;
    }
}
