<?php
/**
 * Storage Manager — S3 via AWS Signature V4 + cURL (no SDK dependency).
 *
 * @package Ugcify\Storage
 */

namespace Ugcify\Storage;

use Ugcify\Helpers\Encryption;
use Ugcify\Helpers\FeatureGate;
use WP_Error;

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

/**
 * Class StorageManager
 *
 * Implements StorageDriverInterface for Amazon S3 (and S3-compatible) storage
 * using raw cURL + AWS Signature Version 4. No aws/aws-sdk-php dependency.
 *
 * SOLID compliance:
 *  - Single Responsibility : S3 I/O only; validation in StorageValidator.
 *  - Open/Closed           : new drivers implement StorageDriverInterface.
 *  - Liskov Substitution   : drop-in replaceable via the interface.
 *  - Interface Segregation : exposes only StorageDriverInterface methods to callers.
 *  - Dependency Inversion  : callers depend on StorageDriverInterface abstraction.
 */
class StorageManager implements StorageDriverInterface {

	/**
	 * WP option key for S3 settings.
	 *
	 * @var string
	 */
	const OPTION_KEY = 'ugcify_storage_settings';

	/**
	 * Whether S3 storage is currently active.
	 *
	 * @return bool
	 */
	public function is_enabled() {
		if ( ! FeatureGate::is_enabled( 's3_storage' ) ) {
			return false;
		}

		$settings = $this->get_settings();

		if ( empty( $settings['enabled'] ) ) {
			return false;
		}

		return ! empty( $settings['bucket'] )
			&& ! empty( $settings['region'] )
			&& ! empty( $settings['access_key_encrypted'] )
			&& ! empty( $settings['secret_key_encrypted'] );
	}

	/**
	 * Alias for backwards-compatibility.
	 *
	 * @return bool
	 */
	public function is_s3_enabled() {
		return $this->is_enabled();
	}

	/**
	 * Get S3 settings from wp_options.
	 *
	 * @return array
	 */
	public function get_settings(): array {
		$saved = get_option( self::OPTION_KEY, array() );
		if ( ! is_array( $saved ) ) {
			$saved = array();
		}

		return wp_parse_args( $saved, self::get_defaults() );
	}

	/**
	 * Get settings safe for API response (no secrets).
	 *
	 * @return array
	 */
	public function get_public_settings(): array {
		$settings = $this->get_settings();

		$has_access_key = ! empty( $settings['access_key_encrypted'] );
		$has_secret_key = ! empty( $settings['secret_key_encrypted'] );

		unset( $settings['access_key_encrypted'], $settings['secret_key_encrypted'] );

		$settings['has_access_key']    = $has_access_key;
		$settings['has_secret_key']    = $has_secret_key;
		$settings['access_key_masked'] = $has_access_key ? $this->mask_access_key() : '';
		$settings['secret_key_masked'] = $has_secret_key ? '••••••••••••••••' : '';
		$settings['is_connected']      = $this->is_enabled();

		return $settings;
	}

	/**
	 * Update S3 settings.
	 *
	 * @param array $input New settings from the admin form.
	 * @return array Updated public-safe settings.
	 */
	public function update_settings( array $input ): array {
		$current = $this->get_settings();

		if ( isset( $input['enabled'] ) ) {
			$current['enabled'] = (bool) $input['enabled'];
		}

		if ( isset( $input['bucket'] ) ) {
			$current['bucket'] = sanitize_text_field( $input['bucket'] );
		}

		if ( isset( $input['region'] ) ) {
			$current['region'] = sanitize_text_field( $input['region'] );
		}

		if ( isset( $input['custom_endpoint'] ) ) {
			$current['custom_endpoint'] = esc_url_raw( $input['custom_endpoint'] );
		}

		if ( isset( $input['path_prefix'] ) ) {
			$prefix                 = sanitize_text_field( $input['path_prefix'] );
			$current['path_prefix'] = '' !== $prefix ? rtrim( $prefix, '/' ) . '/' : '';
		}

		if ( isset( $input['access_key_id'] ) ) {
			$access_key = trim( sanitize_text_field( $input['access_key_id'] ) );
			if ( '' === $access_key ) {
				$current['access_key_encrypted'] = '';
			} elseif ( ! $this->is_masked( $access_key ) ) {
				$current['access_key_encrypted'] = Encryption::encrypt( $access_key );
			}
		}

		if ( isset( $input['secret_access_key'] ) ) {
			$secret_key = trim( sanitize_text_field( $input['secret_access_key'] ) );
			if ( '' === $secret_key ) {
				$current['secret_key_encrypted'] = '';
			} elseif ( ! $this->is_masked( $secret_key ) ) {
				$current['secret_key_encrypted'] = Encryption::encrypt( $secret_key );
			}
		}

		update_option( self::OPTION_KEY, $current );

		return $this->get_public_settings();
	}

	/**
	 * Upload a file to S3 via cURL + AWS Signature V4 (PutObject).
	 *
	 * File path: <path_prefix>/<Y>/<m>/<filename>.<ext>
	 *
	 * @param array  $file        $_FILES entry.
	 * @param int    $product_id  WooCommerce product ID.
	 * @param string $product_sku WooCommerce product SKU.
	 * @param string $date_folder Optional date folder (Y-m-d). Defaults to today (UTC) when empty/invalid.
	 * @return array|WP_Error
	 */
	public function upload( array $file, $product_id, $product_sku, $date_folder = '' ) {
		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings = $this->get_settings();
		$key      = $this->build_object_key( $file['name'], $product_id, $product_sku, $settings, (string) $date_folder );

		return $this->upload_with_key( $file, $key, $settings, $creds );
	}

	/**
	 * Upload a file to S3 using a pre-built object key.
	 *
	 * @param array  $file       $_FILES entry.
	 * @param string $object_key Full object key relative to bucket root.
	 * @return array|WP_Error
	 */
	public function upload_with_object_key( array $file, string $object_key ) {
		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings = $this->get_settings();
		$key      = ltrim( trim( $object_key ), '/' );

		if ( '' === $key ) {
			return new WP_Error(
				's3_invalid_object_key',
				__( 'Invalid S3 object key.', 'ugcify' ),
				array( 'status' => 400 )
			);
		}

		return $this->upload_with_key( $file, $key, $settings, $creds );
	}

	/**
	 * Upload a file to S3 for a resolved object key.
	 *
	 * @param array  $file     $_FILES entry.
	 * @param string $key      Object key relative to bucket root.
	 * @param array  $settings Storage settings.
	 * @param array  $creds    Decrypted credentials.
	 * @return array|WP_Error
	 */
	private function upload_with_key( array $file, string $key, array $settings, array $creds ) {
		$body = file_get_contents( $file['tmp_name'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading local tmp file, not a remote URL.
		if ( false === $body ) {
			return new WP_Error(
				's3_read_file_failed',
				__( 'Failed to read temporary upload file.', 'ugcify' ),
				array( 'status' => 500 )
			);
		}

		$content_type = ! empty( $file['type'] ) ? (string) $file['type'] : 'application/octet-stream';
		$host         = $this->build_host( $settings );
		$url          = $this->build_object_url( $host, $key, $settings );

		$headers = $this->sign_request(
			'PUT',
			$host,
			'/' . $settings['bucket'] . '/' . $key,
			'',
			$body,
			$content_type,
			$settings['region'],
			$creds
		);

		$response = $this->curl_request( 'PUT', $url, $headers, $body );

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

		if ( $response['status'] < 200 || $response['status'] >= 300 ) {
			$message = $this->parse_s3_error( $response['body'] );
			return new WP_Error(
				's3_upload_failed',
				sprintf(
					/* translators: %s: S3 error message */
					__( 'S3 upload failed: %s', 'ugcify' ),
					$message
				),
				array( 'status' => 500 )
			);
		}

		return array(
			'url'  => esc_url_raw( $this->build_public_url( $settings, $key ) ),
			'key'  => $key,
			'type' => $content_type,
			'name' => sanitize_file_name( $file['name'] ),
		);
	}

	/**
	 * Backwards-compatible alias for upload().
	 *
	 * @param array  $file        $_FILES entry.
	 * @param int    $product_id  Product ID.
	 * @param string $product_sku Product SKU.
	 * @return array|WP_Error
	 */
	public function upload_to_s3( array $file, $product_id = 0, $product_sku = 'unknown' ) {
		return $this->upload( $file, $product_id, $product_sku );
	}

	/**
	 * Generate a pre-signed PUT URL for browser-direct uploads (AWS Signature V4 query-string auth).
	 *
	 * File path: <path_prefix>/<Y>/<m>/<filename>.<ext>
	 * URL expires in 15 minutes.
	 *
	 * IMPORTANT — only 'host' is in X-Amz-SignedHeaders. The browser fetch()
	 * call only sends Content-Type and the file body; any extra signed header
	 * that the browser doesn't send will cause a SignatureDoesNotMatch error.
	 * ACL is NOT signed here — set object access via a bucket policy instead.
	 *
	 * @param string $filename     Original file name.
	 * @param string $content_type MIME type.
	 * @param int    $product_id   WooCommerce product ID.
	 * @param string $product_sku  WooCommerce product SKU.
	 * @return array|WP_Error On success: { upload_url, public_url, key }.
	 */
	public function get_presigned_url( $filename, $content_type, $product_id, $product_sku ) {
		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings   = $this->get_settings();
		$key        = $this->build_object_key( $filename, $product_id, $product_sku, $settings );
		$host       = $this->build_host( $settings );
		$region     = $settings['region'];
		$service    = 's3';
		$now        = gmdate( 'Ymd\THis\Z' );
		$date_stamp = gmdate( 'Ymd' );
		$expires    = 900; // 15 minutes.

		$credential = $creds['access_key'] . '/' . $date_stamp . '/' . $region . '/' . $service . '/aws4_request';

		// Only 'host' is signed — the browser must not be required to send any
		// additional headers that it doesn't control (like x-amz-acl).
		$signed_headers = 'host';

		// Build canonical query string — params must be sorted lexicographically.
		$query_params = array(
			'X-Amz-Algorithm'     => 'AWS4-HMAC-SHA256',
			'X-Amz-Credential'    => $credential,
			'X-Amz-Date'          => $now,
			'X-Amz-Expires'       => (string) $expires,
			'X-Amz-SignedHeaders' => $signed_headers,
		);

		ksort( $query_params );
		$canonical_query = http_build_query( $query_params, '', '&', PHP_QUERY_RFC3986 );

		// Canonical URI — path-style: /bucket/key.
		$uri_path = $this->encode_uri_path( '/' . $settings['bucket'] . '/' . $key );

		// Canonical request — payload is UNSIGNED-PAYLOAD for pre-signed browser uploads.
		$canonical_request = implode(
			"\n",
			array(
				'PUT',
				$uri_path,
				$canonical_query,
				'host:' . $host . "\n", // Canonical headers block (one header + blank line).
				$signed_headers,        // Signed headers list.
				'UNSIGNED-PAYLOAD',     // Payload hash placeholder for pre-signed URLs.
			)
		);

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

		$signing_key = $this->derive_signing_key( $creds['secret_key'], $date_stamp, $region, $service );
		$signature   = hash_hmac( 'sha256', $string_to_sign, $signing_key );

		$query_params['X-Amz-Signature'] = $signature;

		$upload_url = 'https://' . $host
			. $uri_path
			. '?' . http_build_query( $query_params, '', '&', PHP_QUERY_RFC3986 );

		$public_url = $this->build_public_url( $settings, $key );

		return array(
			'upload_url' => $upload_url,
			'public_url' => esc_url_raw( $public_url ),
			'key'        => $key,
		);
	}

	/**
	 * Backwards-compatible alias for get_presigned_url().
	 *
	 * @param string $filename     Original file name.
	 * @param string $content_type MIME type.
	 * @param int    $product_id   Product ID.
	 * @param string $product_sku  Product SKU.
	 * @return array|WP_Error
	 */
	public function get_presigned_upload_url( $filename, $content_type, $product_id = 0, $product_sku = 'unknown' ) {
		return $this->get_presigned_url( $filename, $content_type, $product_id, $product_sku );
	}

	/**
	 * Test S3 connection by sending a GetBucketLocation request (GET /?location).
	 *
	 * This is the most reliable connectivity check — it works with minimal IAM
	 * permissions (s3:GetBucketLocation) and returns 200 on success, 403 on bad
	 * credentials, and 404 on a missing bucket.
	 *
	 * @return true|WP_Error
	 */
	public function test_connection() {
		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings = $this->get_settings();
		$host     = $this->build_host( $settings );
		// Path-style: /<bucket>?location=
		$url      = 'https://' . $host . '/' . $settings['bucket'] . '?location=';

		// Sign with bucket in the URI path.
		$headers  = $this->sign_request( 'GET', $host, '/' . $settings['bucket'], 'location=', '', '', $settings['region'], $creds );
		$response = $this->curl_request( 'GET', $url, $headers );

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

		if ( $response['status'] >= 200 && $response['status'] < 300 ) {
			return true;
		}

		$message = $this->parse_s3_error( $response['body'] );
		return new WP_Error(
			's3_connection_failed',
			sprintf(
				/* translators: %s: error message */
				__( 'S3 connection test failed: %s', 'ugcify' ),
				$message ?: 'HTTP ' . $response['status']
			),
			array( 'status' => 400 )
		);
	}

	/**
	 * Delete an S3 object by key (DeleteObject).
	 *
	 * @param string $key S3 object key.
	 * @return true|WP_Error
	 */
	public function delete( $key ) {
		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings = $this->get_settings();
		$host     = $this->build_host( $settings );
		$url      = 'https://' . $host . '/' . rawurlencode( $key );

		$headers  = $this->sign_request( 'DELETE', $host, '/' . $settings['bucket'] . '/' . $key, '', '', '', $settings['region'], $creds );
		$response = $this->curl_request( 'DELETE', $url, $headers );

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

		if ( $response['status'] >= 200 && $response['status'] < 300 ) {
			return true;
		}

		return new WP_Error( 's3_delete_failed', 'HTTP ' . $response['status'] );
	}

	/**
	 * Backwards-compatible alias for delete().
	 *
	 * @param string $key S3 object key.
	 * @return true|WP_Error
	 */
	public function delete_from_s3( $key ) {
		return $this->delete( $key );
	}

	/**
	 * Check whether an S3 object exists by key (signed HEAD request).
	 *
	 * @param string $key S3 object key.
	 * @return bool|WP_Error True when object exists, false for 404, WP_Error otherwise.
	 */
	public function object_exists( string $key ) {
		$key = ltrim( trim( $key ), '/' );
		if ( '' === $key ) {
			return false;
		}

		$creds = $this->get_credentials();
		if ( is_wp_error( $creds ) ) {
			return $creds;
		}

		$settings = $this->get_settings();
		$host     = $this->build_host( $settings );
		$url      = $this->build_object_url( $host, $key, $settings );

		$headers  = $this->sign_request( 'HEAD', $host, '/' . $settings['bucket'] . '/' . $key, '', '', '', $settings['region'], $creds );
		$response = $this->curl_request( 'HEAD', $url, $headers );

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

		if ( 404 === (int) $response['status'] ) {
			return false;
		}

		if ( $response['status'] >= 200 && $response['status'] < 300 ) {
			return true;
		}

		return new WP_Error(
			's3_object_exists_check_failed',
			sprintf(
				/* translators: %d: HTTP status code */
				__( 'Failed to verify object existence (HTTP %d).', 'ugcify' ),
				(int) $response['status']
			),
			array( 'status' => 500 )
		);
	}

	// =========================================================================
	// AWS Signature V4 helpers
	// =========================================================================

	/**
	 * Build AWS Signature V4 Authorization header set for a request.
	 *
	 * Follows the spec exactly:
	 * https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
	 *
	 * @param string $method        HTTP method (GET, PUT, DELETE, HEAD).
	 * @param string $host          Hostname only — no scheme, no trailing slash.
	 * @param string $uri_path      URI path starting with '/' (e.g. '/prefix/date/folder/file.jpg').
	 * @param string $query_string  Pre-encoded canonical query string (e.g. 'location=').
	 * @param string $body          Raw request body (empty string for GET/HEAD/DELETE).
	 * @param string $content_type  Content-Type value (empty string to omit).
	 * @param string $region        AWS region slug (e.g. 'us-east-1').
	 * @param array  $creds         [ 'access_key' => string, 'secret_key' => string ].
	 * @param array  $extra_headers Additional headers to include and sign (lowercase keys).
	 * @return array Associative array of HTTP headers ready to send.
	 */
	private function sign_request(
		string $method,
		string $host,
		string $uri_path,
		string $query_string,
		string $body,
		string $content_type,
		string $region,
		array $creds,
		array $extra_headers = array()
	): array {
		$service    = 's3';
		$now        = gmdate( 'Ymd\THis\Z' );
		$date_stamp = gmdate( 'Ymd' );

		// Step 1 — Payload hash.
		$payload_hash = hash( 'sha256', $body );

		// Step 2 — Canonical URI: encode each path segment, preserve '/'.
		$canonical_uri = $this->encode_uri_path( $uri_path );

		// Step 3 — Build the signed-headers map (all lowercase keys, sorted).
		$headers_map = array( 'host' => $host );

		if ( '' !== $content_type ) {
			$headers_map['content-type'] = $content_type;
		}

		$headers_map['x-amz-content-sha256'] = $payload_hash;
		$headers_map['x-amz-date']           = $now;

		foreach ( $extra_headers as $k => $v ) {
			$headers_map[ strtolower( $k ) ] = $v;
		}

		ksort( $headers_map ); // Lexicographic sort on lowercase keys.

		// Step 4 — Canonical headers string (each "key:trimmed-value\n").
		$canonical_headers = '';
		foreach ( $headers_map as $k => $v ) {
			$canonical_headers .= $k . ':' . trim( (string) $v ) . "\n";
		}

		$signed_headers = implode( ';', array_keys( $headers_map ) );

		// Step 5 — Canonical request.
		$canonical_request = implode(
			"\n",
			array(
				$method,
				$canonical_uri,
				$query_string,
				$canonical_headers,
				$signed_headers,
				$payload_hash,
			)
		);

		// Step 6 — String to sign.
		$credential_scope = $date_stamp . '/' . $region . '/' . $service . '/aws4_request';

		$string_to_sign = implode(
			"\n",
			array(
				'AWS4-HMAC-SHA256',
				$now,
				$credential_scope,
				hash( 'sha256', $canonical_request ),
			)
		);

		// Step 7 — Signing key and signature.
		$signing_key = $this->derive_signing_key( $creds['secret_key'], $date_stamp, $region, $service );
		$signature   = hash_hmac( 'sha256', $string_to_sign, $signing_key );

		// Step 8 — Authorization header.
		$authorization = 'AWS4-HMAC-SHA256'
			. ' Credential=' . $creds['access_key'] . '/' . $credential_scope
			. ', SignedHeaders=' . $signed_headers
			. ', Signature=' . $signature;

		// Assemble the final header set to send with the request.
		$send_headers = array(
			'Authorization'        => $authorization,
			'x-amz-date'           => $now,
			'x-amz-content-sha256' => $payload_hash,
		);

		if ( '' !== $content_type ) {
			$send_headers['Content-Type'] = $content_type;
		}

		foreach ( $extra_headers as $k => $v ) {
			$send_headers[ $k ] = $v;
		}

		return $send_headers;
	}

	/**
	 * Encode a URI path for the canonical request.
	 *
	 * Each path segment is percent-encoded individually; '/' separators are preserved.
	 * This matches the AWS SigV4 requirement exactly.
	 *
	 * @param string $path URI path (must start with '/').
	 * @return string
	 */
	private function encode_uri_path( string $path ): string {
		if ( '/' === $path || '' === $path ) {
			return '/';
		}

		$segments = explode( '/', $path );
		$encoded  = array();

		foreach ( $segments as $segment ) {
			$encoded[] = rawurlencode( $segment );
		}

		return implode( '/', $encoded );
	}

	/**
	 * Derive the AWS Signature V4 signing key via HMAC chain.
	 *
	 * @param string $secret_key AWS Secret Access Key (plaintext).
	 * @param string $date_stamp Date in Ymd format.
	 * @param string $region     AWS region slug.
	 * @param string $service    AWS service name.
	 * @return string Binary 32-byte signing key.
	 */
	private function derive_signing_key( string $secret_key, string $date_stamp, string $region, string $service ): string {
		$k_date    = hash_hmac( 'sha256', $date_stamp, 'AWS4' . $secret_key, true );
		$k_region  = hash_hmac( 'sha256', $region, $k_date, true );
		$k_service = hash_hmac( 'sha256', $service, $k_region, true );

		return hash_hmac( 'sha256', 'aws4_request', $k_service, true );
	}

	// =========================================================================
	// cURL transport
	// =========================================================================

	/**
	 * Execute an HTTP request via cURL.
	 *
	 * @param string $method  HTTP method.
	 * @param string $url     Full URL.
	 * @param array  $headers Associative header array.
	 * @param string $body    Request body.
	 * @return array|WP_Error [ 'status' => int, 'body' => string ] or WP_Error.
	 */
	private function curl_request( string $method, string $url, array $headers, string $body = '' ) {
		if ( ! function_exists( 'curl_init' ) ) {
			return new WP_Error(
				's3_curl_missing',
				__( 'cURL is required for S3 storage but is not available on this server.', 'ugcify' ),
				array( 'status' => 500 )
			);
		}

		// Format headers as "Key: Value" strings.
		$header_lines = array();
		foreach ( $headers as $k => $v ) {
			$header_lines[] = $k . ': ' . $v;
		}

		if ( '' !== $body ) {
			$header_lines[] = 'Content-Length: ' . strlen( $body );
		}

		$ch = curl_init();
		curl_setopt( $ch, CURLOPT_URL, $url );
		curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		curl_setopt( $ch, CURLOPT_HTTPHEADER, $header_lines );
		curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		curl_setopt( $ch, CURLOPT_TIMEOUT, 60 );
		curl_setopt( $ch, CURLOPT_FOLLOWLOCATION, false );

		switch ( strtoupper( $method ) ) {
			case 'PUT':
				curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'PUT' );
				curl_setopt( $ch, CURLOPT_POSTFIELDS, $body );
				break;
			case 'DELETE':
				curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'DELETE' );
				break;
			case 'HEAD':
				curl_setopt( $ch, CURLOPT_NOBODY, true );
				break;
			case 'GET':
			default:
				curl_setopt( $ch, CURLOPT_HTTPGET, true );
				break;
		}

		$response_body = curl_exec( $ch );
		$http_status   = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		$curl_error    = curl_error( $ch );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close -- curl_close is the correct cleanup call here.
		curl_close( $ch );

		if ( '' !== $curl_error ) {
			return new WP_Error(
				's3_curl_error',
				sprintf(
					/* translators: %s: cURL error message */
					__( 'S3 request failed: %s', 'ugcify' ),
					$curl_error
				),
				array( 'status' => 500 )
			);
		}

		return array(
			'status' => $http_status,
			'body'   => (string) $response_body,
		);
	}

	// =========================================================================
	// Private helpers
	// =========================================================================

	/**
	 * Decrypt and return credentials, or WP_Error if missing.
	 *
	 * @return array|WP_Error [ 'access_key' => string, 'secret_key' => string ]
	 */
	private function get_credentials() {
		$settings   = $this->get_settings();
		$access_key = Encryption::decrypt( $settings['access_key_encrypted'] );
		$secret_key = Encryption::decrypt( $settings['secret_key_encrypted'] );

		if ( '' === $access_key || '' === $secret_key ) {
			return new WP_Error(
				's3_credentials_missing',
				__( 'AWS credentials are not configured.', 'ugcify' ),
				array( 'status' => 400 )
			);
		}

		return array(
			'access_key' => $access_key,
			'secret_key' => $secret_key,
		);
	}

	/**
	 * Build the S3 hostname for signing and requests.
	 *
	 * We always use the regional path-style endpoint:
	 *   s3.<region>.amazonaws.com
	 *
	 * This avoids the "PermanentRedirect / must be addressed using the specified
	 * endpoint" error that virtual-hosted-style URLs trigger when the bucket was
	 * created in a region other than us-east-1, because path-style endpoints are
	 * always region-specific and never redirect.
	 *
	 * Custom endpoints (DigitalOcean Spaces, MinIO, etc.) use their own hostname.
	 *
	 * @param array $settings Decoded settings array.
	 * @return string Hostname only — no scheme, no trailing slash.
	 */
	private function build_host( array $settings ): string {
		if ( ! empty( $settings['custom_endpoint'] ) ) {
			$parsed = wp_parse_url( $settings['custom_endpoint'] );
			return $parsed['host'] ?? '';
		}

		// Path-style regional endpoint — never redirects regardless of bucket region.
		return 's3.' . $settings['region'] . '.amazonaws.com';
	}

	/**
	 * Build the full HTTPS URL for an object request.
	 *
	 * Path-style: https://s3.<region>.amazonaws.com/<bucket>/<key>
	 * Custom endpoint path-style: https://<hostname>/<bucket>/<key>
	 *
	 * @param string $host     Hostname from build_host().
	 * @param string $key      S3 object key.
	 * @param array  $settings Settings array.
	 * @return string
	 */
	private function build_object_url( string $host, string $key, array $settings ): string {
		$encoded_path = $this->encode_uri_path( '/' . $key );

		// Both AWS path-style and custom endpoints put the bucket in the URL path.
		return 'https://' . $host . '/' . $settings['bucket'] . $encoded_path;
	}

	/**
	 * Build the public-facing URL for a stored object.
	 *
	 * Returns the virtual-hosted-style URL for public access (CDN-friendly),
	 * while all signed API requests use path-style to avoid redirects.
	 *
	 * @param array  $settings Settings array.
	 * @param string $key      Object key.
	 * @return string
	 */
	private function build_public_url( array $settings, string $key ): string {
		if ( ! empty( $settings['custom_endpoint'] ) ) {
			return rtrim( $settings['custom_endpoint'], '/' ) . '/' . $settings['bucket'] . '/' . $key;
		}

		// Virtual-hosted-style for public URLs (cleaner, CDN-compatible).
		return 'https://' . $settings['bucket'] . '.s3.' . $settings['region'] . '.amazonaws.com/' . $key;
	}

	/**
	 * Build the structured S3 object key.
	 *
	 * Pattern: <path_prefix>/<Y>/<m>/<filename>.<ext>
	 *
	 * @param string $filename    Original filename.
	 * @param int    $product_id  Product ID.
	 * @param string $product_sku Product SKU.
	 * @param array  $settings    Settings array.
	 * @param string $date_folder Optional date folder (Y-m-d).
	 * @return string
	 */
	private function build_object_key( string $filename, $product_id, $product_sku, array $settings, string $date_folder = '' ): string {
		$prefix       = rtrim( $settings['path_prefix'], '/' );
		$ext          = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
		if ( '' === $ext ) {
			$ext = 'bin';
		}
		$hash         = wp_generate_password( 10, false );
		$base_date    = $this->normalize_date_folder( $date_folder );
		$folder_stamp = '' !== $base_date ? strtotime( $base_date . ' 00:00:00 UTC' ) : false;
		if ( false === $folder_stamp ) {
			$folder_stamp = time();
		}

		$year         = gmdate( 'Y', $folder_stamp );
		$month        = gmdate( 'm', $folder_stamp );
		$uploaded_day = gmdate( 'Y-m-d' );
		$product_name = sanitize_file_name( (string) $product_sku );
		if ( '' === $product_name ) {
			$product_name = 'product';
		}

		$base_name = sanitize_file_name( pathinfo( $filename, PATHINFO_FILENAME ) );
		if ( '' === $base_name ) {
			$base_name = 'media';
		}

		$file_name = sanitize_file_name(
			sprintf(
				'%s-up-%s-%s-%s',
				$base_name,
				$uploaded_day,
				$product_name,
				$hash
			)
		);
		if ( '' === $file_name ) {
			$file_name = 'media-up-' . $uploaded_day . '-' . $hash;
		}

		return ( '' !== $prefix ? $prefix . '/' : '' ) . "{$year}/{$month}/{$file_name}.{$ext}";
	}

	/**
	 * Normalize and validate a date folder value.
	 *
	 * @param string $date_folder Candidate date string.
	 * @return string Valid Y-m-d or empty string.
	 */
	private function normalize_date_folder( string $date_folder ): string {
		$value = trim( $date_folder );
		if ( '' === $value || 1 !== preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return '';
		}

		$timestamp = strtotime( $value . ' 00:00:00 UTC' );
		if ( false === $timestamp ) {
			return '';
		}

		$normalized = gmdate( 'Y-m-d', $timestamp );
		return $normalized === $value ? $value : '';
	}

	/**
	 * Parse an S3 XML error response body into a human-readable message.
	 *
	 * @param string $body XML response body.
	 * @return string
	 */
	private function parse_s3_error( string $body ): string {
		if ( '' === $body ) {
			return '';
		}

		// Suppress XML parse errors — best-effort extraction.
		$prev = libxml_use_internal_errors( true );
		$xml  = simplexml_load_string( $body );
		libxml_use_internal_errors( $prev );

		if ( $xml && isset( $xml->Message ) ) {
			return (string) $xml->Message;
		}

		return '';
	}

	/**
	 * Mask the Access Key ID for display (first 4 + last 4 chars).
	 *
	 * @return string
	 */
	private function mask_access_key(): string {
		$settings   = $this->get_settings();
		$access_key = Encryption::decrypt( $settings['access_key_encrypted'] );
		if ( strlen( $access_key ) <= 8 ) {
			return str_repeat( '•', strlen( $access_key ) );
		}

		return substr( $access_key, 0, 4 ) . str_repeat( '•', strlen( $access_key ) - 8 ) . substr( $access_key, -4 );
	}

	/**
	 * Check if input is a masked placeholder (should not overwrite saved key).
	 *
	 * @param string $value Input value.
	 * @return bool
	 */
	private function is_masked( string $value ): bool {
		return 1 === preg_match( '/^[•*]+$/', $value )
			|| 1 === preg_match( '/^[A-Z0-9]{4}[•*]+[A-Z0-9]{4}$/i', $value );
	}

	/**
	 * Default settings.
	 *
	 * @return array
	 */
	public static function get_defaults(): array {
		return array(
			'enabled'              => false,
			'bucket'               => '',
			'region'               => 'us-east-1',
			'access_key_encrypted' => '',
			'secret_key_encrypted' => '',
			'custom_endpoint'      => '',
			'path_prefix'          => 'ugcify-reviews/',
		);
	}
}
