<?php
namespace R2_Offload\Storage;

use R2_Offload\Logger;
use R2_Offload\Settings;

defined( 'ABSPATH' ) || exit;

/**
 * One client for every S3-compatible backend: Amazon S3, Cloudflare R2,
 * Backblaze B2, Wasabi, DigitalOcean Spaces.
 *
 * Transport is the WordPress HTTP API; auth is hand-rolled SigV4 ({@see S3_Signer}).
 * Path-style addressing ({endpoint}/{bucket}/{key}) keeps it portable across clones.
 */
final class S3_Compatible_Client implements Storage_Client {

	private const EMPTY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';

	private Settings $settings;
	private Logger $logger;
	private Rate_Limiter $limiter;
	private string $provider;

	public function __construct( Settings $settings, Logger $logger ) {
		$this->settings = $settings;
		$this->logger   = $logger;
		$this->limiter  = new Rate_Limiter( $settings, $logger );
		$this->provider = (string) $settings->get( 'provider', 's3' );
	}

	public function is_available(): bool {
		return $this->settings->is_configured();
	}

	private function endpoint(): string {
		$tpl = (string) ( Providers::get( $this->provider )['endpoint_tpl'] ?? '' );
		$tpl = strtr(
			$tpl,
			[
				'{region}'                   => (string) $this->settings->get( 'region' ),
				'{account_id}'               => $this->r2_account_id(),
				'{bucket}'                   => (string) $this->settings->get( 'bucket' ),
				'{r2_jurisdiction_infix}'    => $this->r2_jurisdiction_infix(),
			]
		);
		return rtrim( $tpl, '/' );
	}

	/**
	 * Normalize Account ID — accept raw hex or a pasted S3 endpoint URL/host.
	 */
	private function r2_account_id(): string {
		$raw = trim( (string) $this->settings->get( 'account_id' ) );
		if ( $raw === '' ) {
			return '';
		}
		if ( preg_match( '#([a-f0-9]{32})\.(?:eu\.|fedramp\.)?r2\.cloudflarestorage\.com#i', $raw, $m ) ) {
			return strtolower( $m[1] );
		}
		if ( preg_match( '#https?://([a-f0-9]{32})\.#i', $raw, $m ) ) {
			return strtolower( $m[1] );
		}
		return $raw;
	}

	/** Empty string, or ".eu" / ".fedramp" for jurisdiction-locked R2 buckets. */
	private function r2_jurisdiction_infix(): string {
		if ( $this->provider !== 'r2' ) {
			return '';
		}
		$j = strtolower( trim( (string) $this->settings->get( 'r2_jurisdiction', '' ) ) );
		return in_array( $j, [ 'eu', 'fedramp' ], true ) ? '.' . $j : '';
	}

	private function region(): string {
		$r = (string) $this->settings->get( 'region' );
		if ( $this->provider === 'r2' ) {
			return 'auto'; // R2 signs with the literal region "auto"
		}
		return $r !== '' ? $r : 'us-east-1';
	}

	private function signer(): S3_Signer {
		return new S3_Signer(
			(string) $this->settings->get( 'access_key' ),
			(string) $this->settings->get( 'secret_key' ),
			$this->region()
		);
	}

	/** Build the full path-style URL for an object key. */
	private function object_url( string $object_name ): string {
		$bucket = rawurlencode( (string) $this->settings->get( 'bucket' ) );
		$key    = S3_Signer::encode_key( ltrim( $object_name, '/' ) );
		return $this->endpoint() . '/' . $bucket . '/' . $key;
	}

	public function upload_file( string $local_path, string $object_name, string $mime = '' ): array {
		if ( ! file_exists( $local_path ) ) {
			throw new \RuntimeException( 'Local file missing: ' . $local_path );
		}
		$size    = (int) filesize( $local_path );
		$hash    = hash_file( 'sha256', $local_path );
		$headers = [
			'content-type'  => $mime !== '' ? $mime : 'application/octet-stream',
			'cache-control' => 'public, max-age=31536000, immutable',
		];
		if ( $this->settings->get( 'use_legacy_acl' ) && ! $this->settings->get( 'private_mode' ) ) {
			$headers['x-amz-acl'] = 'public-read';
		}

		$resp = $this->limiter->wrap(
			fn() => $this->request( 'PUT', $this->object_url( $object_name ), $headers, $hash, $local_path ),
			'upload:' . $object_name
		);

		if ( $resp['status'] < 200 || $resp['status'] >= 300 ) {
			throw new \RuntimeException(
				sprintf( 'Upload failed (%d): %s', $resp['status'], self::error_detail( $resp['body'] ) ),
				$resp['status']
			);
		}
		return [ 'name' => $object_name, 'size' => $size ];
	}

	public function head_object( string $object_name ): ?array {
		try {
			$resp = $this->limiter->wrap(
				fn() => $this->request( 'HEAD', $this->object_url( $object_name ), [], self::EMPTY_SHA256 ),
				'head:' . $object_name
			);
			if ( $resp['status'] === 404 || $resp['status'] === 403 ) {
				return null;
			}
			if ( $resp['status'] < 200 || $resp['status'] >= 300 ) {
				return null;
			}
			$etag = trim( (string) ( $resp['headers']['etag'] ?? '' ), '"' );
			return [
				'size' => (int) ( $resp['headers']['content-length'] ?? 0 ),
				'md5'  => $etag, // S3 etag == md5 for non-multipart uploads
				'etag' => $etag,
			];
		} catch ( \Throwable $e ) {
			$this->logger->warn( 'Head failed: ' . $e->getMessage(), 's3', [ 'object' => $object_name ] );
			return null;
		}
	}

	public function download_object( string $object_name, string $target_path ): bool {
		try {
			$resp = $this->limiter->wrap(
				fn() => $this->request( 'GET', $this->object_url( $object_name ), [], self::EMPTY_SHA256, null, $target_path ),
				'download:' . $object_name
			);
			return $resp['status'] >= 200 && $resp['status'] < 300 && file_exists( $target_path );
		} catch ( \Throwable $e ) {
			$this->logger->warn( 'Download failed: ' . $e->getMessage(), 's3', [ 'object' => $object_name ] );
			return false;
		}
	}

	public function delete_object( string $object_name ): void {
		try {
			$this->limiter->wrap(
				fn() => $this->request( 'DELETE', $this->object_url( $object_name ), [], self::EMPTY_SHA256 ),
				'delete:' . $object_name
			);
		} catch ( \Throwable $e ) {
			$this->logger->warn( 'Delete failed: ' . $e->getMessage(), 's3', [ 'object' => $object_name ] );
		}
	}

	public function test_connection(): array {
		if ( ! $this->is_available() ) {
			return [ 'ok' => false, 'message' => 'Missing bucket / access key / secret key.' ];
		}
		try {
			$bucket = rawurlencode( (string) $this->settings->get( 'bucket' ) );
			$url    = $this->endpoint() . '/' . $bucket . '/?list-type=2&max-keys=1';
			$resp   = $this->limiter->wrap(
				fn() => $this->request( 'GET', $url, [], self::EMPTY_SHA256 ),
				'test'
			);
			if ( $resp['status'] >= 200 && $resp['status'] < 300 ) {
				$hint = $this->provider === 'r2' ? ' via ' . $this->endpoint() : '';
				return [ 'ok' => true, 'message' => 'Bucket reachable (' . Providers::label( $this->provider ) . ')' . $hint . '.' ];
			}
			$detail = self::error_detail( $resp['body'] );
			$msg    = sprintf( 'HTTP %d: %s', $resp['status'], $detail );
			if ( $this->provider === 'r2' && $resp['status'] === 404 ) {
				$msg .= ' — checked ' . $this->endpoint() . '/' . (string) $this->settings->get( 'bucket' )
					. '. If the bucket was created with Jurisdiction = EU (or FedRAMP), set that under Cloudflare Account ID → Jurisdiction and re-test. A wrong Account ID also returns NoSuchBucket.';
			}
			return [ 'ok' => false, 'message' => $msg ];
		} catch ( \Throwable $e ) {
			return [ 'ok' => false, 'message' => $e->getMessage() ];
		}
	}

	/**
	 * Perform one signed HTTP request.
	 *
	 * Throws (with the HTTP status as the exception code) on transient failures
	 * — network errors, 408, 429, 5xx — so {@see Rate_Limiter::wrap()} retries.
	 * Returns the response for any terminal status (2xx/4xx).
	 *
	 * @return array{status:int,headers:array<string,string>,body:string}
	 */
	private function request( string $method, string $url, array $headers, string $payload_hash, ?string $upload_file = null, ?string $stream_to = null ): array {
		$amz_date = gmdate( 'Ymd\THis\Z' );
		$signed   = $this->signer()->sign( $method, $url, $headers, $payload_hash, $amz_date );

		$args = [
			'method'      => $method,
			'headers'     => $signed,
			'timeout'     => 120,
			'redirection' => 0,
			'sslverify'   => true,
		];
		if ( $upload_file !== null ) {
			$args['body'] = file_get_contents( $upload_file ); // images are small; full read is fine
		}
		if ( $stream_to !== null ) {
			$args['stream']   = true;
			$args['filename'] = $stream_to;
		}

		$resp = wp_remote_request( $url, $args );

		if ( is_wp_error( $resp ) ) {
			throw new \RuntimeException( 'Transport error: ' . $resp->get_error_message(), 0 );
		}

		$status = (int) wp_remote_retrieve_response_code( $resp );
		if ( $status === 408 || $status === 429 || $status >= 500 ) {
			throw new \RuntimeException( 'Transient HTTP ' . $status, $status );
		}

		return [
			'status'  => $status,
			'headers' => self::headers_array( $resp ),
			'body'    => $stream_to !== null ? '' : (string) wp_remote_retrieve_body( $resp ),
		];
	}

	/** wp_remote_retrieve_headers() may return a dictionary object OR a plain array. */
	private static function headers_array( $resp ): array {
		$h = wp_remote_retrieve_headers( $resp );
		if ( is_object( $h ) && method_exists( $h, 'getAll' ) ) {
			$h = $h->getAll();
		}
		return array_change_key_case( (array) $h, CASE_LOWER );
	}

	private static function error_detail( string $body ): string {
		if ( $body === '' ) {
			return 'no response body';
		}
		if ( preg_match( '#<Message>(.*?)</Message>#s', $body, $m ) ) {
			return trim( $m[1] );
		}
		return substr( trim( $body ), 0, 200 );
	}
}
