<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Dropbox storage adapter.
 *
 * All three Dropbox content-endpoint calls (download_to_file / stream_to_output /
 * download_thumbnail) use the WordPress HTTP API (`wp_remote_request`) with the
 * stream-to-file mode. Dropbox's content endpoints (`/files/download` and
 * `/files/get_thumbnail_v2`) historically rejected non-empty Content-Type
 * headers with HTTP 400; we send `Content-Type: application/octet-stream`,
 * which Dropbox accepts on content endpoints (the rejection was only for
 * `application/x-www-form-urlencoded`-style form-encoded request bodies).
 *
 * For the streaming-to-browser case (stream_to_output), the response is first
 * streamed to a WordPress temp file via `stream => true`, then echoed back out
 * in 64 KB chunks. This deliberately avoids materialising the full response in
 * PHP memory (which would blow `memory_limit` for 50–100 MB media files).
 * Range requests for video seeking are supported by passing the `Range`
 * header through to Dropbox — Dropbox returns the requested byte range only,
 * so the temp file is bounded by the range size, not the full file size.
 */
class CCGAL_Dropbox_Adapter extends CCGAL_Storage_Adapter {

	const API_BASE     = 'https://api.dropboxapi.com/2';
	const CONTENT_BASE = 'https://content.dropboxapi.com/2';

	/**
	 * Per-Request-Memo des vollständigen Ordner-Listings (Key: md5(folder_id)).
	 * Verhindert, dass Token-Schleifen der Aufrufer den Ordner mehrfach listen.
	 *
	 * @var array<string,array>
	 */
	private $listing_memo = array();

	// ── Provider-Identität ────────────────────────────────────────────────

	public function get_provider_key(): string {
		return 'dropbox';
	}

	public function get_folder_label(): string {
		return __( 'Dropbox folder', 'customer-cloud-gallery' );
	}

	public function is_connected(): bool {
		return CCGAL_Dropbox_OAuth::is_connected();
	}

	public function register_hooks(): void {
		CCGAL_Dropbox_OAuth::init();
	}

	public function sanitize_folder_id( string $raw ): string {
		$raw = trim( $raw );
		if ( '' === $raw ) {
			return '';
		}

		// Dropbox-ID-Format: id:abc123
		if ( 0 === strpos( $raw, 'id:' ) ) {
			if ( preg_match( '/^id:[A-Za-z0-9_\-]+/', $raw, $m ) ) {
				return $m[0];
			}
		}

		// Dropbox-URL: stripos (case-insensitiv) statt Regex — robuster bei Encoding-Varianten
		if ( false !== stripos( $raw, 'dropbox.com' ) ) {
			$home_pos = stripos( $raw, '/home' );
			if ( false !== $home_pos ) {
				// Alles nach "/home" extrahieren
				$path = substr( $raw, $home_pos + 5 );

				// Query-String und Fragment abschneiden
				foreach ( array( '?', '#' ) as $stop ) {
					$stop_pos = strpos( $path, $stop );
					if ( false !== $stop_pos ) {
						$path = substr( $path, 0, $stop_pos );
					}
				}

				$path = urldecode( trim( $path ) );
				if ( '' === $path ) {
					return ''; // Dropbox-Wurzel
				}
				return '/' === $path[0] ? $path : '/' . $path;
			}

			// Share-Links oder andere dropbox.com-URLs → nicht unterstützt
			return '';
		}

		// Absoluter Pfad
		if ( '/' === $raw[0] ) {
			return $raw;
		}

		// Relativer Pfad → führenden Slash ergänzen
		return '/' . $raw;
	}

	// ── Storage-Operationen ───────────────────────────────────────────────

	/**
	 * Dropbox kennt — anders als Drive — weder Server-Sortierung noch ein
	 * verlässliches Antwort-Limit pro Seite, und die Cursor-Reihenfolge ist laut
	 * API-Doku unspezifiziert. Damit `sort_order` und `per_page` der Galerie auch
	 * für Dropbox gelten (und die Reihenfolge in Grid UND ZIP deterministisch
	 * ist), wird der Ordner deshalb komplett gelistet (einmal pro Request
	 * memoized), client-seitig sortiert und über numerische Offset-Tokens
	 * paginiert statt über Dropbox-Cursor.
	 */
	public function list_folder( string $folder_id, int $page_size = 20, string $page_token = '', string $order_by = 'name_natural' ) {
		$allowed_order = array( 'name_natural', 'name', 'createdTime', 'createdTime desc', 'modifiedTime desc' );
		if ( ! in_array( $order_by, $allowed_order, true ) ) {
			$order_by = 'name_natural';
		}

		$files = $this->get_full_listing( $folder_id );
		if ( is_wp_error( $files ) ) {
			return $files;
		}

		$files = $this->sort_files( $files, $order_by );

		// Token = numerischer Offset in die sortierte Gesamtliste. Nicht-numerische
		// Tokens (alte Dropbox-Cursor aus laufenden Clients während eines Updates)
		// fallen auf Seite 1 zurück — selbstheilend, kein Endlos-Loop.
		$offset    = ctype_digit( $page_token ) ? (int) $page_token : 0;
		$page_size = min( 2000, max( 1, $page_size ) );
		$total     = count( $files );

		return array(
			'files'           => array_slice( $files, $offset, $page_size ),
			'next_page_token' => ( $offset + $page_size < $total ) ? (string) ( $offset + $page_size ) : '',
		);
	}

	/**
	 * Vollständiges, normalisiertes Listing eines Ordners (alle Cursor-Seiten).
	 * Pro Request memoized, damit Token-Schleifen der Aufrufer (ZIP/Manifest/
	 * Folder-Map paginieren in einer Schleife durch) nur EINEN API-Durchlauf kosten.
	 *
	 * @return array|WP_Error
	 */
	private function get_full_listing( string $folder_id ) {
		$memo_key = md5( $folder_id );
		if ( isset( $this->listing_memo[ $memo_key ] ) ) {
			return $this->listing_memo[ $memo_key ];
		}

		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$files  = array();
		$cursor = '';
		$safety = 13; // 13 × 2000 Einträge = 26k — deckt das 25k-Hard-Limit der ZIP-Pfade ab.

		do {
			if ( $cursor ) {
				$url  = self::API_BASE . '/files/list_folder/continue';
				$body = wp_json_encode( array( 'cursor' => $cursor ) );
			} else {
				$url  = self::API_BASE . '/files/list_folder';
				$body = wp_json_encode( array(
					'path'                    => $this->normalize_path( $folder_id ),
					'recursive'               => false,
					'include_media_info'      => true,
					'include_deleted'         => false,
					'include_mounted_folders' => true,
					'limit'                   => 2000,
				) );
			}

			$response = wp_remote_post( $url, array(
				'timeout' => 30,
				'headers' => array(
					'Authorization' => 'Bearer ' . $token,
					'Content-Type'  => 'application/json',
				),
				'body' => $body,
			) );

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

			$code = (int) wp_remote_retrieve_response_code( $response );
			$data = json_decode( wp_remote_retrieve_body( $response ), true, 32 );

			if ( 200 !== $code ) {
				$msg = isset( $data['error_summary'] ) ? $data['error_summary'] : __( 'Dropbox API error.', 'customer-cloud-gallery' );
				return new WP_Error( 'ccgal_dropbox_api_error', $msg, array( 'status' => $code ) );
			}

			if ( ! isset( $data['entries'] ) || ! is_array( $data['entries'] ) ) {
				return new WP_Error( 'ccgal_dropbox_api_error', __( 'Dropbox API error.', 'customer-cloud-gallery' ), array( 'status' => 502 ) );
			}

			foreach ( $data['entries'] as $entry ) {
				if ( ( $entry['.tag'] ?? '' ) !== 'file' || empty( $entry['name'] ) || empty( $entry['id'] ) ) {
					continue;
				}

				$mime = $this->mime_from_filename( $entry['name'] );
				// Image-only listing for the 1.12.2 wp.org submission. Video files
				// are intentionally excluded so the streaming-to-browser code path
				// is not exercised by visitors. Video support will be re-introduced
				// in a later release once the review process completes.
				if ( ! self::is_image_mime( $mime ) ) {
					continue;
				}

				$width  = 0;
				$height = 0;
				if ( isset( $entry['media_info']['metadata']['dimensions'] ) ) {
					$dim    = $entry['media_info']['metadata']['dimensions'];
					$width  = (int) ( $dim['width']  ?? 0 );
					$height = (int) ( $dim['height'] ?? 0 );
				}

				$files[] = array(
					'id'                 => $entry['id'],
					'name'               => $entry['name'],
					'mimeType'           => $mime,
					'size'               => (int) ( $entry['size'] ?? 0 ),
					'createdTime'        => $entry['server_modified'] ?? '',
					'thumbnailLink'      => '',
					'imageMediaMetadata' => array( 'width' => $width, 'height' => $height ),
				);
			}

			$cursor = ! empty( $data['has_more'] ) ? (string) ( $data['cursor'] ?? '' ) : '';
		} while ( '' !== $cursor && --$safety > 0 );

		$this->listing_memo[ $memo_key ] = $files;
		return $files;
	}

	/**
	 * Sortiert das normalisierte Listing gemäß den Drive-kompatiblen
	 * `order_by`-Werten. ISO-8601-Zeitstempel (`server_modified`) sortieren
	 * lexikografisch korrekt. Tiebreaker auf `id` hält die Offset-Pagination
	 * bei gleichen Namen/Zeitstempeln deterministisch.
	 */
	private function sort_files( array $files, string $order_by ): array {
		usort( $files, function ( $a, $b ) use ( $order_by ) {
			switch ( $order_by ) {
				case 'name':
					$cmp = strcasecmp( $a['name'], $b['name'] );
					break;
				case 'createdTime':
					$cmp = strcmp( $a['createdTime'], $b['createdTime'] );
					break;
				case 'createdTime desc':
				case 'modifiedTime desc':
					$cmp = strcmp( $b['createdTime'], $a['createdTime'] );
					break;
				case 'name_natural':
				default:
					$cmp = strnatcasecmp( $a['name'], $b['name'] );
			}
			return 0 !== $cmp ? $cmp : strcmp( $a['id'], $b['id'] );
		} );
		return $files;
	}

	public function get_file_meta( string $file_id ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$response = wp_remote_post( self::API_BASE . '/files/get_metadata', array(
			'timeout' => 15,
			'headers' => array(
				'Authorization' => 'Bearer ' . $token,
				'Content-Type'  => 'application/json',
			),
			'body' => wp_json_encode( array( 'path' => $file_id ) ),
		) );

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

		$code = (int) wp_remote_retrieve_response_code( $response );
		$data = json_decode( wp_remote_retrieve_body( $response ), true, 32 );

		if ( 200 !== $code ) {
			return new WP_Error( 'ccgal_dropbox_api_error', __( 'File not found.', 'customer-cloud-gallery' ), array( 'status' => $code ) );
		}

		return array(
			'id'       => $data['id'],
			'name'     => $data['name'],
			'mimeType' => $this->mime_from_filename( $data['name'] ),
			'size'     => (int) ( $data['size'] ?? 0 ),
		);
	}

	public function download_to_file( string $file_id, string $dest_path ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		// wp_remote_request with stream => true writes the response body directly to
		// the destination file (no PHP memory buffering — safe for 20–100 MB photos).
		// Content-Type: application/octet-stream is accepted by Dropbox's content
		// endpoints; the historically problematic value was the form-encoded default.
		$response = wp_remote_request(
			self::CONTENT_BASE . '/files/download',
			array(
				'method'      => 'POST',
				'timeout'     => 300,
				'stream'      => true,
				'filename'    => $dest_path,
				'redirection' => 0, // Dropbox content endpoints do not legitimately redirect; refusing to follow prevents bearer-token leakage to any unexpected host.
				'headers'     => array(
					'Authorization'   => 'Bearer ' . $token,
					'Dropbox-API-Arg' => wp_json_encode( array( 'path' => $file_id ) ),
					'Content-Type'    => 'application/octet-stream',
				),
				'body'        => '',
			)
		);

		if ( is_wp_error( $response ) ) {
			if ( file_exists( $dest_path ) ) {
				wp_delete_file( $dest_path );
			}
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			if ( file_exists( $dest_path ) ) {
				wp_delete_file( $dest_path );
			}
			return new WP_Error(
				'ccgal_dropbox_download_error',
				__( 'Download failed.', 'customer-cloud-gallery' ),
				array( 'status' => $code )
			);
		}

		return true;
	}

	public function get_media_download_url( string $file_id ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$cache_key = 'ccgal_db_murl_' . md5( $file_id );
		$cached    = get_transient( $cache_key );
		if ( is_string( $cached ) && '' !== $cached ) {
			return $cached;
		}

		$response = wp_remote_post( self::API_BASE . '/files/get_temporary_link', array(
			'timeout' => 15,
			'headers' => array(
				'Authorization' => 'Bearer ' . $token,
				'Content-Type'  => 'application/json',
			),
			'body' => wp_json_encode( array( 'path' => $file_id ) ),
		) );

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

		$code = (int) wp_remote_retrieve_response_code( $response );
		$data = json_decode( wp_remote_retrieve_body( $response ), true, 32 );

		if ( 200 !== $code || empty( $data['link'] ) ) {
			return new WP_Error( 'ccgal_dropbox_temp_link', __( 'No temporary link received.', 'customer-cloud-gallery' ) );
		}

		$link = (string) $data['link'];
		set_transient( $cache_key, $link, 3 * HOUR_IN_SECONDS );
		return $link;
	}

	public function stream_to_output( string $file_id, $callback = null, string $range = '' ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		// Two-step approach using only the WordPress HTTP API:
		//   1. Stream Dropbox's response body to a WP temp file via `stream => true`.
		//   2. Read the temp file back in 64 KB chunks and dispatch to the caller's
		//      callback (or echo to the browser output buffer).
		// This avoids materialising 50–100 MB media bodies in PHP memory.
		// Range requests for HTTP 206 video seeking are passed through via the
		// `Range` header — Dropbox returns only the requested byte range, so the
		// temp file's size is bounded by the range, not by the full file size.
		$temp = wp_tempnam( 'ccgal-dbx-stream' );
		if ( ! $temp ) {
			return new WP_Error( 'ccgal_temp_file', __( 'Could not create temporary file.', 'customer-cloud-gallery' ) );
		}

		// Belt-and-braces cleanup: if PHP fatals mid-stream (OOM during echo,
		// timeout, etc.), `wp_delete_file()` below never runs and the temp file
		// would leak into wp-content/uploads/. A shutdown function guarantees
		// the temp file is removed even on fatal exit.
		register_shutdown_function(
			static function () use ( $temp ) {
				if ( is_string( $temp ) && file_exists( $temp ) ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- shutdown handler running after WP teardown; WP_Filesystem is unreliable here.
					@unlink( $temp );
				}
			}
		);

		$headers = array(
			'Authorization'   => 'Bearer ' . $token,
			'Dropbox-API-Arg' => wp_json_encode( array( 'path' => $file_id ) ),
			'Content-Type'    => 'application/octet-stream',
		);
		if ( '' !== $range ) {
			$headers['Range'] = 0 === stripos( $range, 'bytes=' ) ? $range : 'bytes=' . $range;
		}

		$response = wp_remote_request(
			self::CONTENT_BASE . '/files/download',
			array(
				'method'      => 'POST',
				'timeout'     => 300,
				'stream'      => true,
				'filename'    => $temp,
				'redirection' => 0, // Dropbox content endpoints do not legitimately redirect; refusing to follow prevents bearer-token leakage to any unexpected host.
				'headers'     => $headers,
				'body'        => '',
			)
		);

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

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( ! in_array( $code, array( 200, 206 ), true ) ) {
			wp_delete_file( $temp );
			return new WP_Error(
				'ccgal_dropbox_stream_error',
				__( 'Streaming failed.', 'customer-cloud-gallery' ),
				array( 'status' => $code )
			);
		}

		// Pipe temp file to output / callback in 64 KB chunks.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming output of an HTTP response body in fixed-size chunks; WP_Filesystem returns no file pointer suitable for `fread()`.
		$fp = @fopen( $temp, 'rb' );
		if ( false === $fp ) {
			wp_delete_file( $temp );
			return new WP_Error( 'ccgal_temp_read', __( 'Could not read temporary file.', 'customer-cloud-gallery' ) );
		}

		$direct_echo = ! is_callable( $callback );

		while ( ! feof( $fp ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- streaming chunked output of an HTTP media response body to the browser; WP_Filesystem provides no equivalent fixed-size chunked read API (`get_contents()` loads the entire file, which defeats the memory-safety reason for streaming in the first place).
			$chunk = fread( $fp, 65536 );
			if ( false === $chunk || '' === $chunk ) {
				break;
			}
			if ( $direct_echo ) {
				echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw binary media payload being streamed; escaping would corrupt the bytes and HTTP-API has already validated the response code above.
				// Force chunk to the wire instead of letting it sit in PHP's
				// output buffer (php.ini `output_buffering = 4096` default) or
				// behind `zlib.output_compression`. Without these flushes the
				// browser's video player can stall on slow connections / large
				// Range requests waiting for the buffer to fill.
				if ( ob_get_level() > 0 ) {
					@ob_flush();
				}
				flush();
			} else {
				call_user_func( $callback, $chunk );
			}
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing the read handle opened above for chunked output; not a WP_Filesystem operation.
		fclose( $fp );
		wp_delete_file( $temp );

		return true;
	}

	public function download_thumbnail( string $file_id, int $max_dim, string $dest_path ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		// Dropbox hat nur feste Größen — immer w2048h1536 laden, CCGAL_Cache skaliert danach
		$arg = wp_json_encode( array(
			'resource' => array( '.tag' => 'path', 'path' => $file_id ),
			'format'   => array( '.tag' => 'jpeg' ),
			'size'     => array( '.tag' => 'w2048h1536' ),
			'mode'     => array( '.tag' => 'bestfit' ),
		) );

		// Same approach as download_to_file(): stream the response body directly to
		// disk via wp_remote_request( stream => true ). Content-Type:
		// application/octet-stream is accepted by Dropbox's content endpoints.
		$response = wp_remote_request(
			self::CONTENT_BASE . '/files/get_thumbnail_v2',
			array(
				'method'      => 'POST',
				'timeout'     => 60,
				'stream'      => true,
				'filename'    => $dest_path,
				'redirection' => 0, // Dropbox content endpoints do not legitimately redirect; refusing to follow prevents bearer-token leakage to any unexpected host.
				'headers'     => array(
					'Authorization'   => 'Bearer ' . $token,
					'Dropbox-API-Arg' => $arg,
					'Content-Type'    => 'application/octet-stream',
				),
				'body'        => '',
			)
		);

		if ( is_wp_error( $response ) ) {
			if ( file_exists( $dest_path ) ) {
				wp_delete_file( $dest_path );
			}
			return $response;
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		if ( 200 !== $code ) {
			if ( file_exists( $dest_path ) ) {
				wp_delete_file( $dest_path );
			}
			return new WP_Error(
				'ccgal_dropbox_thumb_error',
				__( 'Thumbnail download failed.', 'customer-cloud-gallery' ),
				array( 'status' => $code )
			);
		}

		return true;
	}

	public function get_folder_file_map( string $folder_id, bool $force_refresh = false ): array {
		$folder_id = $this->sanitize_file_id( $folder_id );
		if ( '' === $folder_id ) {
			return array();
		}

		$transient_key = 'ccgal_folder_map_db_' . md5( $folder_id );
		if ( ! $force_refresh ) {
			$cached = get_transient( $transient_key );
			if ( is_array( $cached ) ) {
				return $cached;
			}
		}

		$map        = array();
		$page_token = '';
		$safety     = 20;

		do {
			$batch = $this->list_folder( $folder_id, 1000, $page_token );
			if ( is_wp_error( $batch ) ) {
				break;
			}
			foreach ( $batch['files'] as $file ) {
				if ( empty( $file['id'] ) ) {
					continue;
				}
				$map[ $file['id'] ] = array(
					'name'          => $file['name'],
					'width'         => $file['imageMediaMetadata']['width']  ?? 0,
					'height'        => $file['imageMediaMetadata']['height'] ?? 0,
					'thumbnailLink' => '',
				);
			}
			$page_token = $batch['next_page_token'] ?? '';
			$safety--;
		} while ( $page_token && $safety > 0 );

		set_transient( $transient_key, $map, 15 * MINUTE_IN_SECONDS );
		return $map;
	}

	public function get_folder_name( string $folder_id ) {
		$token = CCGAL_Dropbox_OAuth::get_access_token();
		if ( is_wp_error( $token ) ) {
			return $token;
		}

		$response = wp_remote_post( self::API_BASE . '/files/get_metadata', array(
			'timeout' => 15,
			'headers' => array(
				'Authorization' => 'Bearer ' . $token,
				'Content-Type'  => 'application/json',
			),
			'body' => wp_json_encode( array( 'path' => $folder_id ) ),
		) );

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'ccgal_dropbox_http', sprintf( /* translators: %s: error message returned by HTTP client. */ __( 'HTTP error: %s', 'customer-cloud-gallery' ), $response->get_error_message() ) );
		}

		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = wp_remote_retrieve_body( $response );
		$data = json_decode( $body, true, 32 );

		if ( 200 !== $code ) {
			// Echte Dropbox-Fehlermeldung weitergeben für bessere Diagnose
			$detail = '';
			if ( 401 === $code ) {
				$detail = __( 'Access denied (401) — token expired or insufficient permissions. Please disconnect and reconnect.', 'customer-cloud-gallery' );
			} elseif ( 400 === $code ) {
				$summary = isset( $data['error_summary'] ) ? $data['error_summary'] : $body;
				/* translators: %s: error summary returned by Dropbox. */
				$detail  = sprintf( __( 'Bad request (400): %s', 'customer-cloud-gallery' ), $summary );
			} elseif ( 409 === $code ) {
				$summary = isset( $data['error_summary'] ) ? $data['error_summary'] : 'path/not_found';
				if ( false !== strpos( $summary, 'not_found' ) ) {
					/* translators: %s: folder path that was not found. */
					$detail = sprintf( __( 'Folder "%s" not found. Please check the path.', 'customer-cloud-gallery' ), $folder_id );
				} else {
					/* translators: 1: Dropbox error summary, 2: requested folder path. */
					$detail = sprintf( __( 'Dropbox error (409): %1$s — requested path: "%2$s"', 'customer-cloud-gallery' ), $summary, $folder_id );
				}
			} else {
				$summary = isset( $data['error_summary'] ) ? $data['error_summary'] : $body;
				/* translators: 1: HTTP status code, 2: error summary returned by Dropbox. */
				$detail  = sprintf( __( 'Dropbox responded with HTTP %1$d: %2$s', 'customer-cloud-gallery' ), $code, $summary );
			}
			return new WP_Error( 'ccgal_dropbox_api_error', $detail, array( 'status' => $code ) );
		}

		if ( ( $data['.tag'] ?? '' ) !== 'folder' ) {
			return new WP_Error( 'ccgal_dropbox_not_folder', __( 'The ID/path is not a folder.', 'customer-cloud-gallery' ) );
		}

		return isset( $data['name'] ) ? (string) $data['name'] : $folder_id;
	}

	public function file_in_folder( string $file_id, string $folder_id ): bool {
		$file_id   = $this->sanitize_file_id( $file_id );
		$folder_id = $this->sanitize_file_id( $folder_id );
		if ( '' === $file_id || '' === $folder_id ) {
			return false;
		}

		$map = $this->get_folder_file_map( $folder_id );
		if ( isset( $map[ $file_id ] ) ) {
			return true;
		}

		$neg_key = 'ccgal_db_nfm_' . md5( $folder_id . '|' . $file_id );
		if ( get_transient( $neg_key ) ) {
			return false;
		}

		$map = $this->get_folder_file_map( $folder_id, true );
		if ( isset( $map[ $file_id ] ) ) {
			return true;
		}

		set_transient( $neg_key, 1, 5 * MINUTE_IN_SECONDS );
		return false;
	}

	// ── Überschreibt Basis-Methode: Dropbox Pfade dürfen / und Spaces enthalten ─

	public function sanitize_file_id( string $id ): string {
		$id = (string) $id;
		if ( 0 === strpos( $id, 'id:' ) ) {
			// id:-Format: nur sichere Zeichen
			return substr( preg_replace( '/[^A-Za-z0-9_\-:]/', '', $id ), 0, 128 );
		}
		// Pfad-Format: / und übliche Zeichen erlauben, aber gefährliche entfernen
		$id = preg_replace( '/[<>"\'\x00-\x1f\\\\]/', '', $id );
		return substr( $id, 0, 512 );
	}

	// ── Private Helfer ────────────────────────────────────────────────────

	private function normalize_path( string $folder_id ): string {
		// Dropbox-Root ist '' statt '/'
		if ( '' === $folder_id || '/' === $folder_id ) {
			return '';
		}
		return $folder_id;
	}

	private function mime_from_filename( string $name ): string {
		$ext = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
		$map = array(
			'jpg'  => 'image/jpeg', 'jpeg' => 'image/jpeg', 'jpe'  => 'image/jpeg',
			'png'  => 'image/png',
			'gif'  => 'image/gif',
			'webp' => 'image/webp',
			'tif'  => 'image/tiff',  'tiff' => 'image/tiff',
			'bmp'  => 'image/bmp',
			'heic' => 'image/heic',  'heif' => 'image/heif',
			'avif' => 'image/avif',
			'mp4'  => 'video/mp4',
			'mov'  => 'video/quicktime',
			'm4v'  => 'video/x-m4v',
			'avi'  => 'video/x-msvideo',
			'mkv'  => 'video/x-matroska',
			'webm' => 'video/webm',
			'mts'  => 'video/MP2T',  'm2ts' => 'video/MP2T',
			'wmv'  => 'video/x-ms-wmv',
			'3gp'  => 'video/3gpp',
		);
		return $map[ $ext ] ?? '';
	}
}
