<?php
/**
 * S3-compatible destination — base class for AWS, Wasabi, and any other
 * endpoint that speaks the S3 API (Backblaze B2, DigitalOcean Spaces,
 * MinIO, Cloudflare R2, …).
 *
 * Uses the hand-rolled {@see S3Client} (SigV4 + streaming cURL). No AWS SDK,
 * no Composer dependency — works on any host with the cURL extension.
 *
 * Subclasses (AwsDestination, WasabiDestination) only need to override
 * {@see resolve_endpoint()} to point at their provider's hostname; everything
 * else — upload/download/delete/test — is inherited.
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

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

class S3compatDestination extends AbstractDestination {

	public static function type(): string  { return 's3'; }
	public static function label(): string { return 'S3-Compatible'; }

	public static function config_schema(): array {
		return [
			[ 'key' => 'endpoint',      'label' => 'Endpoint URL',       'type' => 'text',     'required' => true,
			  'help'  => 'Full HTTPS URL, e.g. https://nyc3.digitaloceanspaces.com or https://s3.minio.local' ],
			[ 'key' => 'region',        'label' => 'Region',             'type' => 'text',     'default' => 'us-east-1' ],
			[ 'key' => 'bucket',        'label' => 'Bucket',             'type' => 'text',     'required' => true ],
			[ 'key' => 'access_key',    'label' => 'Access key ID',      'type' => 'text',     'required' => true, 'secret' => true ],
			[ 'key' => 'secret_key',    'label' => 'Secret access key',  'type' => 'password', 'required' => true, 'secret' => true ],
			[ 'key' => 'prefix',        'label' => 'Object key prefix',  'type' => 'text',     'default' => 'wordpress/' ],
			[ 'key' => 'storage_class', 'label' => 'Storage class',      'type' => 'select',
			  'options' => [ 'STANDARD' ], 'default' => 'STANDARD' ],
			[ 'key' => 'sse',           'label' => 'Server-side encryption', 'type' => 'select',
			  'options' => [ 'none', 'AES256' ], 'default' => 'AES256' ],
		];
	}

	/**
	 * Generic walkthrough for "any provider that speaks the S3 API". Lists
	 * the popular options (Backblaze B2, Cloudflare R2, DigitalOcean Spaces,
	 * MinIO) with their endpoint hostnames and where each provider's console
	 * lives. AWS / Wasabi have their own dedicated walkthroughs (those
	 * destinations subclass this one but override setup_help).
	 */
	public static function setup_help(): array {
		return [
			'title'    => 'Setup help: where do I get the Access key, Secret, and Endpoint?',
			'provider' => 'S3-compatible storage',
			'steps' => [
				'Sign in to your provider\'s console (links and endpoints listed below) and create a private bucket.',
				'Generate an Access Key + Secret Key scoped to that bucket (the wording varies by provider, e.g. "API Key", "Application Key", "Spaces access key", etc.). <strong>Copy the secret immediately</strong>, because every S3-compatible provider hides it after the create-key dialog closes.',
				'Set <strong>Endpoint URL</strong> to your provider\'s S3 endpoint (see the table in the box below). Always use the HTTPS variant.',
				'Set <strong>Region</strong> to whatever the bucket\'s region is. Different providers care about this to different degrees: some accept any string; some reject mismatches.',
				'Save. The "Test" button does a round-trip: head bucket → put 1-byte probe → delete probe. Any IAM misconfig surfaces here, not three hours into a real backup.',
			],
			'callouts' => [
				[
					'label' => 'Common providers',
					'value' =>
						"Backblaze B2  →  https://s3.<region>.backblazeb2.com   (region from bucket details)\n" .
						"Cloudflare R2 →  https://<account-id>.r2.cloudflarestorage.com   (region = auto)\n" .
						"DigitalOcean  →  https://<region>.digitaloceanspaces.com   (e.g. nyc3, sfo3, ams3)\n" .
						"Linode OBJ    →  https://<cluster>.linodeobjects.com   (e.g. us-east-1)\n" .
						"MinIO (self-hosted)  →  whatever URL your MinIO is on (set MINIO_BROWSER_REDIRECT_URL)\n" .
						"iDrive e2     →  https://<region>.idrivee2-<n>.com\n" .
						"Storj         →  https://gateway.storjshare.io",
					'help'  => 'Endpoint formats from each provider\'s docs. Swap the placeholders for your account\'s values.',
				],
			],
		];
	}

	public function test( DestinationRecord $dest ): bool {
		// head_bucket() alone passes if the IAM principal can list the bucket
		// but cannot PUT — meaning backups would silently fail at upload time.
		// Do a proper roundtrip: head, put a 1-byte probe, delete it. Catches
		// the most common S3 misconfig (read-only or wrong-bucket-region IAM).
		if ( ! $this->client( $dest )->head_bucket() ) {
			throw new \RuntimeException(
				 esc_html( 'Bucket not reachable. Check endpoint, region, bucket name, and that the access key has s3:HeadBucket permission.'
			 ) );
		}
		$probe_local = self::temp_path( 'backreload-s3-probe' );
		if ( ! $probe_local || false === @file_put_contents( $probe_local, 'backreload-probe' ) ) {
			throw new \RuntimeException( esc_html(  'Could not create local probe file for the S3 test.'  )  );
		}
		$probe_name = '.backreload-test-' . wp_generate_password( 8, false, false );
		try {
			$this->upload( $dest, $probe_local, $probe_name );
		} catch ( \Throwable $e ) {
			@unlink( $probe_local ); // 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.
			$msg = $e->getMessage();
			if ( false !== stripos( $msg, 'AccessDenied' ) || false !== stripos( $msg, '403' ) ) {
				throw new \RuntimeException(
					 esc_html( 'Bucket reachable but PUT denied, the access key needs `s3:PutObject` (and `s3:DeleteObject` for retention). Original error: ' . $msg
				 ) );
			}
			throw $e;
		}
		@unlink( $probe_local ); // 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.
		try {
			$this->delete( $dest, $probe_name );
		} catch ( \Throwable $e ) { /* ignore — tiny file, best-effort */ }
		return true;
	}

	public function upload( DestinationRecord $dest, string $local_path, string $remote_name ): string {
		$key = $this->object_key( $dest, $remote_name );
		$this->client( $dest )->put_object( $key, $local_path, $this->upload_headers( $dest ) );
		return $key;
	}

	public function download( DestinationRecord $dest, string $remote_name, string $local_path ): bool {
		$key = $this->object_key( $dest, $remote_name );
		return $this->client( $dest )->get_object( $key, $local_path );
	}

	public function delete( DestinationRecord $dest, string $remote_name ): bool {
		$key = $this->object_key( $dest, $remote_name );
		return $this->client( $dest )->delete_object( $key );
	}

	public function list_backups( DestinationRecord $dest ): array {
		$client = $this->client( $dest );
		$prefix = trim( (string) ( $dest->config['prefix'] ?? '' ) );
		$prefix = '' === $prefix ? '' : trailingslashit( ltrim( $prefix, '/' ) );

		// Top level: the "folders" directly under the configured prefix.
		$top = $client->list_objects( $prefix, '/' );
		$out = [];
		foreach ( $top['prefixes'] as $folder ) {
			$ext_id = basename( rtrim( $folder, '/' ) );
			if ( 0 !== strpos( $ext_id, 'bk_' ) ) {
				continue;
			}
			$listing  = $client->list_objects( $folder, '/' );
			$parts    = [];
			$manifest = null;
			foreach ( $listing['objects'] as $obj ) {
				$key = (string) $obj['key'];
				// Destination-relative name = key minus the configured prefix
				// (e.g. "wordpress/bk_xyz/db.sql.gz" → "bk_xyz/db.sql.gz").
				$rel  = ( '' !== $prefix && 0 === strpos( $key, $prefix ) )
					? substr( $key, strlen( $prefix ) )
					: $key;
				$base = basename( $rel );
				if ( '' === $base ) {
					continue;
				}
				if ( \Backreload\Core\Manifest::FILENAME === $base ) {
					$tmp = self::temp_path( 'backreload-mf' );
					if ( $tmp && $client->get_object( $key, $tmp ) ) {
						$manifest = \Backreload\Core\Manifest::read( $tmp );
					}
					if ( $tmp ) {
						@unlink( $tmp ); // 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.
					}
					continue;
				}
				$parts[] = [ 'name' => $rel, 'size' => (int) $obj['size'] ];
			}
			if ( empty( $parts ) && null === $manifest ) {
				continue;
			}
			$out[] = [
				'ext_id'       => $ext_id,
				'has_manifest' => null !== $manifest,
				'manifest'     => $manifest,
				'parts'        => $parts,
			];
		}
		return $out;
	}

	/* ─────────────────────────────  internals  ───────────────────────── */

	/** Build an authenticated S3 client from the destination's config. */
	protected function client( DestinationRecord $dest ): S3Client {
		$c = $dest->config;
		return new S3Client(
			$this->resolve_endpoint( $dest ),
			(string) ( $c['region']     ?? 'us-east-1' ),
			(string) ( $c['bucket']     ?? '' ),
			(string) ( $c['access_key'] ?? '' ),
			(string) ( $c['secret_key'] ?? '' )
		);
	}

	/**
	 * Resolve the endpoint URL. Base implementation reads the explicit
	 * `endpoint` config field. AWS / Wasabi subclasses override this to
	 * derive the hostname from the chosen region.
	 */
	protected function resolve_endpoint( DestinationRecord $dest ): string {
		$endpoint = (string) ( $dest->config['endpoint'] ?? '' );
		if ( '' === $endpoint ) {
			throw new \RuntimeException( esc_html(  'No S3 endpoint configured for this destination.'  )  );
		}
		// Require HTTPS — SigV4 UNSIGNED-PAYLOAD uploads are only safe over TLS.
		if ( 0 !== strpos( $endpoint, 'https://' ) ) {
			throw new \RuntimeException( esc_html(  'S3 endpoint must use HTTPS.'  )  );
		}
		// SSRF guard: a user-supplied endpoint must not resolve to an internal /
		// loopback / cloud-metadata address. Legitimate self-hosted MinIO/NAS on
		// a LAN can be allowed back via the `backreload_allow_private_host` filter.
		$reason = \Backreload\Core\Ssrf::unsafe_url_reason( $endpoint );
		if ( null !== $reason ) {
			throw new \RuntimeException( esc_html( 'S3 endpoint rejected: ' . $reason ) );
		}
		return $endpoint;
	}

	/** Full object key: configured prefix + the per-backup relative name. */
	protected function object_key( DestinationRecord $dest, string $remote_name ): string {
		$prefix = trim( (string) ( $dest->config['prefix'] ?? '' ) );
		$prefix = '' === $prefix ? '' : trailingslashit( ltrim( $prefix, '/' ) );
		return $prefix . ltrim( $remote_name, '/' );
	}

	/**
	 * x-amz-* headers applied to every upload, derived from config:
	 *   - server-side encryption (AES256 / aws:kms)
	 *   - storage class (STANDARD_IA, GLACIER, …)
	 *
	 * @return array<string,string>
	 */
	protected function upload_headers( DestinationRecord $dest ): array {
		$headers = [];
		$sse = (string) ( $dest->config['sse'] ?? 'none' );
		if ( 'none' !== $sse && '' !== $sse ) {
			$headers['x-amz-server-side-encryption'] = $sse;
		}
		$class = (string) ( $dest->config['storage_class'] ?? 'STANDARD' );
		if ( '' !== $class && 'STANDARD' !== $class ) {
			$headers['x-amz-storage-class'] = $class;
		}
		return $headers;
	}
}
