<?php
/**
 * Minimal Amazon S3 client with hand-rolled Signature Version 4.
 *
 * Why hand-rolled instead of aws/aws-sdk-php:
 *   - The SDK is ~5 MB and pulls dozens of transitive Composer packages.
 *     Bundling it into a WordPress plugin bloats the download massively.
 *   - We only need four operations (HEAD bucket, PUT/GET/DELETE object).
 *     SigV4 for those is a few hundred lines, fully auditable here.
 *
 * Design notes:
 *   - Uploads and downloads STREAM through cURL straight to/from disk
 *     (CURLOPT_UPLOAD + CURLOPT_INFILE / CURLOPT_FILE) so memory stays flat
 *     regardless of archive-part size.
 *   - Uploads use the `UNSIGNED-PAYLOAD` content hash — valid over HTTPS —
 *     so we never have to read a multi-hundred-MB file just to hash it.
 *   - Path-style addressing (scheme + endpoint host + /bucket/key) for maximum
 *     compatibility (MinIO, DigitalOcean Spaces, Wasabi, Backblaze B2, and
 *     existing AWS buckets all accept it).
 *
 * Works with any S3-compatible endpoint: AWS, Wasabi, Backblaze B2,
 * DigitalOcean Spaces, MinIO, Cloudflare R2, etc.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

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

class S3Client {

	private const SERVICE   = 's3';
	private const ALGORITHM = 'AWS4-HMAC-SHA256';
	/** sha256 of an empty string — payload hash for bodyless requests. */
	private const EMPTY_HASH = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

	private string $endpoint;   // scheme + host, e.g. s3.<region>.amazonaws.com (no trailing slash)
	private string $host;       // host only, e.g. s3.<region>.amazonaws.com
	private string $region;
	private string $bucket;
	private string $access_key;
	private string $secret_key;

	public function __construct(
		string $endpoint, string $region, string $bucket,
		string $access_key, string $secret_key
	) {
		$this->endpoint   = rtrim( $endpoint, '/' );
		$this->host       = (string) wp_parse_url( $this->endpoint, PHP_URL_HOST );
		$port             = wp_parse_url( $this->endpoint, PHP_URL_PORT );
		if ( $port ) {
			$this->host .= ':' . $port; // host header must include a non-default port
		}
		$this->region     = $region ?: 'us-east-1';
		$this->bucket     = $bucket;
		$this->access_key = $access_key;
		$this->secret_key = $secret_key;

		if ( ! function_exists( 'curl_init' ) ) {
			throw new \RuntimeException(
				 esc_html( 'PHP cURL extension is required for S3 destinations. Ask your host to enable it.'
			 ) );
		}
	}

	/* ───────────────────────────  Operations  ─────────────────────────── */

	/**
	 * Verify the bucket is reachable + credentials valid. Throws on failure.
	 */
	public function head_bucket(): bool {
		$res = $this->request( 'HEAD', '/' . $this->bucket, self::EMPTY_HASH );
		if ( $res['status'] >= 200 && $res['status'] < 300 ) {
			return true;
		}
		if ( 403 === $res['status'] ) {
			throw new \RuntimeException( esc_html(  'Access denied: check the access key, secret key, and bucket permissions.'  )  );
		}
		if ( 404 === $res['status'] ) {
			throw new \RuntimeException( esc_html( "Bucket not found: {$this->bucket} (check the name and region)." ) );
		}
		throw new \RuntimeException( esc_html(  'S3 bucket check failed: HTTP ' . $res['status'] . ' ' . $res['error']  )  );
	}

	/**
	 * Upload a local file to the given object key. Streams from disk.
	 *
	 * @param array<string,string> $extra_headers e.g. x-amz-server-side-encryption.
	 */
	public function put_object( string $key, string $local_file, array $extra_headers = [] ): void {
		$size = @filesize( $local_file );
		if ( false === $size ) {
			throw new \RuntimeException( esc_html(  "Cannot read file for upload: {$local_file}"  )  );
		}
		$fh = @fopen( $local_file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; uploads/downloads stream multi-GB archives via direct handles.
		if ( ! $fh ) {
			throw new \RuntimeException( esc_html(  "Cannot open file for upload: {$local_file}"  )  );
		}

		try {
			// UNSIGNED-PAYLOAD: skip hashing the (potentially huge) body.
			$headers = array_merge( $extra_headers, [
				'x-amz-content-sha256' => 'UNSIGNED-PAYLOAD',
			] );
			$res = $this->request(
				'PUT',
				'/' . $this->bucket . '/' . $key,
				'UNSIGNED-PAYLOAD',
				$headers,
				[ 'upload_handle' => $fh, 'upload_size' => $size ]
			);
		} finally {
			fclose( $fh ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; uploads/downloads stream multi-GB archives via direct handles.
		}

		if ( $res['status'] < 200 || $res['status'] >= 300 ) {
			throw new \RuntimeException(
				 esc_html( "S3 upload failed for {$key}: HTTP {$res['status']} {$res['error']}"
			 ) );
		}
	}

	/**
	 * Download an object to a local path. Streams to disk.
	 *
	 * @return bool true on success, false if the object doesn't exist (404).
	 */
	public function get_object( string $key, string $local_file ): bool {
		$out = @fopen( $local_file, 'wb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem buffers whole files and cannot stream; uploads/downloads stream multi-GB archives via direct handles.
		if ( ! $out ) {
			throw new \RuntimeException( esc_html(  "Cannot open local file for download: {$local_file}"  )  );
		}
		try {
			$res = $this->request(
				'GET',
				'/' . $this->bucket . '/' . $key,
				self::EMPTY_HASH,
				[],
				[ 'download_handle' => $out ]
			);
		} finally {
			fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem buffers whole files and cannot stream; uploads/downloads stream multi-GB archives via direct handles.
		}

		if ( $res['status'] >= 200 && $res['status'] < 300 ) {
			return true;
		}
		// Failure — the body we streamed to disk is an XML error, not data. Bin it.
		@unlink( $local_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Deletes the plugin's own backup/temp files; behaviour-equivalent to wp_delete_file for these plugin-controlled paths.
		if ( 404 === $res['status'] ) {
			return false;
		}
		throw new \RuntimeException( esc_html(  "S3 download failed for {$key}: HTTP {$res['status']} {$res['error']}"  )  );
	}

	/**
	 * List objects under a prefix (ListObjectsV2). Follows continuation tokens.
	 *
	 * With $delimiter = '/', the result's `prefixes` are the "folders" directly
	 * under $prefix (e.g. "bk_xyz/") and `objects` are the files at that level.
	 *
	 * @return array{objects:array<int,array{key:string,size:int}>,prefixes:array<int,string>}
	 */
	public function list_objects( string $prefix = '', string $delimiter = '' ): array {
		$objects  = [];
		$prefixes = [];
		$token    = '';

		do {
			$query = [ 'list-type' => '2' ];
			if ( '' !== $prefix ) {
				$query['prefix'] = $prefix;
			}
			if ( '' !== $delimiter ) {
				$query['delimiter'] = $delimiter;
			}
			if ( '' !== $token ) {
				$query['continuation-token'] = $token;
			}

			$res = $this->request( 'GET', '/' . $this->bucket, self::EMPTY_HASH, [], [], $query );
			if ( $res['status'] < 200 || $res['status'] >= 300 ) {
				throw new \RuntimeException( esc_html(  'S3 list failed: HTTP ' . $res['status'] . ' ' . $res['error']  )  );
			}

			// Strip the default namespace so SimpleXML child access works
			// plainly. Match either quote style — some S3-compatible servers
			// emit single-quoted attributes.
			$clean = preg_replace( '/\sxmlns=("[^"]*"|\'[^\']*\')/', '', (string) $res['body'], 1 );
			$doc   = @simplexml_load_string( (string) $clean );
			if ( false === $doc ) {
				throw new \RuntimeException( esc_html(  'S3 list returned an unparseable response.'  )  );
			}

			foreach ( $doc->Contents as $c ) {
				$objects[] = [ 'key' => (string) $c->Key, 'size' => (int) $c->Size ];
			}
			foreach ( $doc->CommonPrefixes as $p ) {
				$prefixes[] = (string) $p->Prefix;
			}

			$truncated = isset( $doc->IsTruncated ) && 'true' === (string) $doc->IsTruncated;
			$token     = $truncated ? (string) $doc->NextContinuationToken : '';
		} while ( '' !== $token );

		return [ 'objects' => $objects, 'prefixes' => $prefixes ];
	}

	/** Delete an object. A missing object (404) is treated as success. */
	public function delete_object( string $key ): bool {
		$res = $this->request( 'DELETE', '/' . $this->bucket . '/' . $key, self::EMPTY_HASH );
		if ( ( $res['status'] >= 200 && $res['status'] < 300 ) || 404 === $res['status'] ) {
			return true;
		}
		throw new \RuntimeException( esc_html(  "S3 delete failed for {$key}: HTTP {$res['status']} {$res['error']}"  )  );
	}

	/* ───────────────────────────  SigV4 core  ─────────────────────────── */

	/**
	 * Sign + execute a request via cURL.
	 *
	 * @param string               $method        HTTP verb.
	 * @param string               $path          Resource path beginning with `/`,
	 *                                             e.g. /bucket/key. Will be URI-encoded.
	 * @param string               $payload_hash  Hex sha256 of the body, or 'UNSIGNED-PAYLOAD'.
	 * @param array<string,string>  $extra_headers Additional x-amz-* headers to sign + send.
	 * @param array<string,mixed>   $io            Streaming handles: upload_handle/upload_size
	 *                                             or download_handle.
	 * @param array<string,string>  $query         Query-string params (sorted + signed).
	 * @return array{status:int,error:string,body:string}
	 */
	private function request(
		string $method, string $path, string $payload_hash,
		array $extra_headers = [], array $io = [], array $query = []
	): array {
		$now        = time();
		$amz_date   = gmdate( 'Ymd\THis\Z', $now );
		$date_stamp = gmdate( 'Ymd', $now );

		$canonical_uri = $this->uri_encode( $path, true );

		// Canonical query string: params sorted by key, every byte percent-encoded.
		$canonical_query = '';
		if ( $query ) {
			ksort( $query );
			$pairs = [];
			foreach ( $query as $qk => $qv ) {
				$pairs[] = $this->uri_encode( (string) $qk, false ) . '=' . $this->uri_encode( (string) $qv, false );
			}
			$canonical_query = implode( '&', $pairs );
		}

		// ── Headers to sign. Everything we send that starts with x-amz- MUST
		//    be signed, plus host. content-length / content-type that cURL
		//    adds itself are deliberately left unsigned (S3 doesn't require it).
		$headers = array_change_key_case( $extra_headers, CASE_LOWER );
		$headers['host']                 = $this->host;
		$headers['x-amz-date']           = $amz_date;
		$headers['x-amz-content-sha256'] = $payload_hash;
		ksort( $headers );

		$canonical_headers = '';
		foreach ( $headers as $k => $v ) {
			$canonical_headers .= $k . ':' . trim( (string) $v ) . "\n";
		}
		$signed_headers = implode( ';', array_keys( $headers ) );

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

		$scope = $date_stamp . '/' . $this->region . '/' . self::SERVICE . '/aws4_request';
		$string_to_sign = implode( "\n", [
			self::ALGORITHM,
			$amz_date,
			$scope,
			hash( 'sha256', $canonical_request ),
		] );

		// Derive the signing key.
		$k_date    = hash_hmac( 'sha256', $date_stamp,        'AWS4' . $this->secret_key, true );
		$k_region  = hash_hmac( 'sha256', $this->region,      $k_date,    true );
		$k_service = hash_hmac( 'sha256', self::SERVICE,       $k_region,  true );
		$k_signing = hash_hmac( 'sha256', 'aws4_request',      $k_service, true );
		$signature = hash_hmac( 'sha256', $string_to_sign,     $k_signing );

		$authorization = self::ALGORITHM
			. ' Credential=' . $this->access_key . '/' . $scope
			. ', SignedHeaders=' . $signed_headers
			. ', Signature=' . $signature;

		// ── Send via the WordPress HTTP API.
		//
		// Backups move multi-GB archive parts, so the body must STREAM between
		// disk and the socket and never load into memory. We therefore make the
		// request with wp_remote_request() and customise the underlying cURL
		// handle through WordPress's own `http_api_curl` hook (the sanctioned
		// way to set cURL options for the HTTP API): uploads read the part from
		// a file handle (CURLOPT_INFILE) and downloads are written straight to a
		// file handle via a streaming write callback. Our hook runs after the
		// HTTP API has configured the handle, so these settings take effect.
		$url = $this->endpoint . $canonical_uri
			. ( '' !== $canonical_query ? '?' . $canonical_query : '' );

		$wp_headers = [ 'Authorization' => $authorization ];
		foreach ( $headers as $k => $v ) {
			if ( 'host' === $k ) continue; // WordPress sets Host from the URL
			$wp_headers[ $k ] = (string) $v;
		}

		$error_body  = '';
		$curl_filter = static function ( $handle ) use ( $io, &$error_body ) {
			// phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_setopt -- Setting cURL options through the http_api_curl hook is the documented way to customise the WordPress HTTP API transport; required here only to stream the body to/from a file handle so multi-GB parts never load into memory.
			curl_setopt( $handle, CURLOPT_CONNECTTIMEOUT, 20 );
			curl_setopt( $handle, CURLOPT_TIMEOUT, 0 ); // no overall cap — big transfers can be slow
			if ( isset( $io['upload_handle'] ) ) {
				curl_setopt( $handle, CURLOPT_POSTFIELDS, null );
				curl_setopt( $handle, CURLOPT_UPLOAD, true );
				curl_setopt( $handle, CURLOPT_INFILE, $io['upload_handle'] );
				curl_setopt( $handle, CURLOPT_INFILESIZE, (int) $io['upload_size'] );
				// On error S3 returns a small XML body — capture it for the message.
				curl_setopt( $handle, CURLOPT_WRITEFUNCTION, static function ( $c, $data ) use ( &$error_body ) {
					$error_body .= $data;
					return strlen( $data );
				} );
			} elseif ( isset( $io['download_handle'] ) ) {
				// Override the HTTP API's in-memory write callback so the body is
				// streamed straight to disk instead of buffered in RAM.
				curl_setopt( $handle, CURLOPT_WRITEFUNCTION, static function ( $c, $data ) use ( $io ) {
					return fwrite( $io['download_handle'], $data ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Streaming cURL write callback: appends each received chunk to the open download handle so multi-GB objects never load into memory; WP_Filesystem cannot stream.
				} );
			}
			// phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_setopt
		};

		add_action( 'http_api_curl', $curl_filter );
		$res = wp_remote_request( $url, [
			'method'      => $method,
			'headers'     => $wp_headers,
			'timeout'     => 0,
			'redirection' => 0,
			'sslverify'   => true,
			'decompress'  => false,
			'body'        => '',
		] );
		remove_action( 'http_api_curl', $curl_filter );

		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'S3 connection error: ' . $res->get_error_message() ) );
		}

		$status = (int) wp_remote_retrieve_response_code( $res );
		$body   = (string) wp_remote_retrieve_body( $res );

		// Extract a human message from S3's XML error body when present.
		$err_msg = '';
		$raw     = isset( $io['upload_handle'] ) ? $error_body : $body;
		if ( '' !== $raw && false !== strpos( $raw, '<Message>' ) ) {
			if ( preg_match( '#<Message>(.*?)</Message>#s', $raw, $m ) ) {
				$err_msg = trim( $m[1] );
			}
		}

		return [
			'status' => $status,
			'error'  => $err_msg,
			'body'   => isset( $io['download_handle'] ) ? '' : $body,
		];
	}

	/**
	 * AWS-flavoured URI encoding. Encodes every byte except the unreserved
	 * set (A-Z a-z 0-9 - _ . ~). Slashes are preserved when $keep_slashes
	 * is true (they separate the bucket/key path segments).
	 */
	private function uri_encode( string $s, bool $keep_slashes = true ): string {
		$out = '';
		$len = strlen( $s );
		for ( $i = 0; $i < $len; $i++ ) {
			$c = $s[ $i ];
			if (
				( $c >= 'A' && $c <= 'Z' ) ||
				( $c >= 'a' && $c <= 'z' ) ||
				( $c >= '0' && $c <= '9' ) ||
				'-' === $c || '_' === $c || '.' === $c || '~' === $c
			) {
				$out .= $c;
			} elseif ( '/' === $c && $keep_slashes ) {
				$out .= '/';
			} else {
				$out .= '%' . strtoupper( bin2hex( $c ) );
			}
		}
		return $out;
	}
}
