<?php
/**
 * Google Drive HTTP client.
 *
 * Talks to the Drive REST API v3 directly via wp_remote_*  — no SDK bundle.
 * Same shape as our S3Client: a thin, focused wrapper around the calls we
 * actually need (upload, download, delete, list, folder lookup, token
 * refresh). Lives in the destinations namespace because Drive *is* a
 * storage destination conceptually, even though its protocol is HTTP+JSON
 * rather than the S3 family's signed-XML.
 *
 * Why not the google/apiclient Composer package?
 *   - It bundles ~500 files / ~30 MB into vendor/. WP.org reviewers and
 *     other-plugin conflicts are both real problems.
 *   - For our narrow use (a few endpoints), the SDK's abstractions buy
 *     us nothing — direct HTTP is fewer lines AND easier to debug when
 *     Google returns a 4xx.
 *   - Matches the precedent we set for S3 (also zero-dep SigV4 client).
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

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

class GoogleClient {

	/**
	 * Threshold for switching from simple multipart upload to a resumable
	 * upload session. Google requires resumable for anything above 5 MB to
	 * survive intermittent network glitches. Below the threshold we use the
	 * simpler multipart upload (one POST), above it we open a session and
	 * stream the bytes in chunks.
	 *
	 * 5 MB is Google's documented cutoff, but for our backup parts (which
	 * are tens to hundreds of MB) we'll always be on the resumable path —
	 * the multipart path is just there for the rare tiny manifest.json case.
	 */
	const RESUMABLE_THRESHOLD = 5 * 1048576;

	/**
	 * Resumable upload chunk size. Must be a multiple of 256 KB per Google's
	 * spec. Larger = fewer round trips but more memory + bigger retry hit on
	 * failure. 8 MB is the sweet spot on shared hosting where memory_limit
	 * may be tight; matches the chunk size the Drive client SDK defaults to.
	 */
	const CHUNK_SIZE = 8 * 1048576;

	private DestinationRecord $dest;

	public function __construct( DestinationRecord $dest ) {
		$this->dest = $dest;
	}

	/**
	 * Ensure the access token is fresh. Refreshes via the stored refresh
	 * token if `expires_at` is in the past (with a 30-second cushion to
	 * cover clock skew between PHP and Google's edge).
	 *
	 * Mutates $this->dest->config in place AND persists with $dest->save()
	 * so the new token survives the request — Drive uploads can span many
	 * minutes via the tick engine, and re-refreshing on every tick would
	 * exhaust Google's refresh-token rate limit.
	 *
	 * @throws \RuntimeException when the refresh fails (token revoked, app
	 *                            credentials wrong, network down, etc.).
	 */
	public function ensure_token(): void {
		$expires_at = (int) ( $this->dest->config['expires_at'] ?? 0 );
		if ( $expires_at > time() + 30 ) {
			return; // still valid
		}
		$refresh = (string) ( $this->dest->config['refresh_token'] ?? '' );
		if ( '' === $refresh ) {
			throw new \RuntimeException( esc_html( 'Google Drive access token expired and no refresh token is stored. Re-authorize the destination from the Destinations page.' ) );
		}
		$res = wp_remote_post( 'https://oauth2.googleapis.com/token', [
			'timeout'   => 20,
			'sslverify' => true,
			'body'      => [
				'client_id'     => (string) ( $this->dest->config['client_id'] ?? '' ),
				'client_secret' => (string) ( $this->dest->config['client_secret'] ?? '' ),
				'refresh_token' => $refresh,
				'grant_type'    => 'refresh_token',
			],
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Google token refresh failed: ' . $res->get_error_message() ) );
		}
		$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		if ( wp_remote_retrieve_response_code( $res ) >= 400 || empty( $body['access_token'] ) ) {
			$err = $body['error_description'] ?? $body['error'] ?? 'unknown';
			throw new \RuntimeException( esc_html( "Google token refresh failed: {$err}. Re-authorize the destination from the Destinations page." ) );
		}
		$this->dest->config['access_token'] = (string) $body['access_token'];
		$this->dest->config['expires_at']   = time() + (int) ( $body['expires_in'] ?? 3600 );
		// Google sometimes rotates the refresh token too — honor that when it does.
		if ( ! empty( $body['refresh_token'] ) ) {
			$this->dest->config['refresh_token'] = (string) $body['refresh_token'];
		}
		$this->dest->save();
	}

	/**
	 * Common headers for every API call: bearer auth, JSON accept. Adds the
	 * `If-Match` / `Content-Type` etc. on a per-call basis.
	 */
	private function auth_headers(): array {
		return [ 'Authorization' => 'Bearer ' . (string) $this->dest->config['access_token'] ];
	}

	/**
	 * Ping `/about` and return true if we got a valid quota response.
	 * Used by the destination's test() method to give a fast green-tick when
	 * the user clicks Test.
	 */
	public function test_connection(): bool {
		$this->ensure_token();
		$res = wp_remote_get( 'https://www.googleapis.com/drive/v3/about?fields=storageQuota', [
			'timeout' => 15,
			'headers' => $this->auth_headers(),
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Drive API unreachable: ' . $res->get_error_message() ) );
		}
		$code = wp_remote_retrieve_response_code( $res );
		if ( $code >= 400 ) {
			$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
			$msg  = $body['error']['message'] ?? "HTTP {$code}";
			throw new \RuntimeException( esc_html( "Drive API rejected the request: {$msg}" ) );
		}
		return true;
	}

	/**
	 * Read storage quota from /about. Returns [used, limit] in BYTES. Both
	 * can be 0 (free accounts with no quota / unlimited business accounts).
	 *
	 * @return array{used:int,total:int,free:int}
	 */
	public function quota(): array {
		$this->ensure_token();
		$res = wp_remote_get( 'https://www.googleapis.com/drive/v3/about?fields=storageQuota', [
			'timeout' => 15,
			'headers' => $this->auth_headers(),
		] );
		if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) >= 400 ) {
			return [ 'used' => 0, 'total' => 0, 'free' => 0 ];
		}
		$body  = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		$used  = (int) ( $body['storageQuota']['usage'] ?? 0 );
		$total = (int) ( $body['storageQuota']['limit'] ?? 0 );
		return [
			'used'  => $used,
			'total' => $total,
			'free'  => $total > 0 ? max( 0, $total - $used ) : 0,
		];
	}

	/**
	 * Find a folder by name inside a parent, creating it if missing. Returns
	 * the folder's Drive file id. Idempotent — safe to call on every upload.
	 *
	 * Why this lives in the client: backup parts live inside `bk_<ext_id>/`
	 * subfolders inside the configured root folder, mirroring the layout we
	 * use on Local / FTP / S3 destinations. Drive's API uses ids, not paths,
	 * so we resolve names → ids here.
	 *
	 * @param string  $name      Folder name to find or create
	 * @param ?string $parent_id Parent folder id; null = root of My Drive
	 */
	public function find_or_create_folder( string $name, ?string $parent_id = null ): string {
		$this->ensure_token();
		$parent = $parent_id ?? 'root';
		// Drive's search query language: name match + folder type + parent.
		// Backticks in name would break the query; reject them defensively.
		if ( false !== strpos( $name, "'" ) || false !== strpos( $name, '\\' ) ) {
			throw new \RuntimeException( esc_html( "Folder name contains unsafe characters: {$name}" ) );
		}
		$query = "name = '" . $name . "' and mimeType = 'application/vnd.google-apps.folder' and '" . $parent . "' in parents and trashed = false";
		$res   = wp_remote_get(
			'https://www.googleapis.com/drive/v3/files?' . http_build_query( [
				'q'      => $query,
				'fields' => 'files(id,name)',
			] ),
			[ 'timeout' => 15, 'headers' => $this->auth_headers() ]
		);
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Drive folder lookup failed: ' . $res->get_error_message() ) );
		}
		$body  = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		$files = (array) ( $body['files'] ?? [] );
		if ( ! empty( $files[0]['id'] ) ) {
			return (string) $files[0]['id'];
		}

		// Doesn't exist — create it.
		$res = wp_remote_post( 'https://www.googleapis.com/drive/v3/files', [
			'timeout' => 20,
			'headers' => array_merge( $this->auth_headers(), [ 'Content-Type' => 'application/json' ] ),
			'body'    => wp_json_encode( [
				'name'     => $name,
				'mimeType' => 'application/vnd.google-apps.folder',
				'parents'  => [ $parent ],
			] ),
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Drive folder create failed: ' . $res->get_error_message() ) );
		}
		$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		if ( empty( $body['id'] ) ) {
			$msg = $body['error']['message'] ?? 'unknown';
			throw new \RuntimeException( esc_html( "Drive folder create failed: {$msg}" ) );
		}
		return (string) $body['id'];
	}

	/**
	 * Find a file by name inside a parent. Returns the file id or null.
	 *
	 * @param string $name      File name (the part after the last `/`)
	 * @param string $parent_id Drive id of the folder to search in
	 */
	public function find_file( string $name, string $parent_id ): ?string {
		$this->ensure_token();
		if ( false !== strpos( $name, "'" ) || false !== strpos( $name, '\\' ) ) {
			return null;
		}
		$query = "name = '" . $name . "' and '" . $parent_id . "' in parents and trashed = false";
		$res   = wp_remote_get(
			'https://www.googleapis.com/drive/v3/files?' . http_build_query( [
				'q'      => $query,
				'fields' => 'files(id,name,size)',
			] ),
			[ 'timeout' => 15, 'headers' => $this->auth_headers() ]
		);
		if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) >= 400 ) {
			return null;
		}
		$body  = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		$files = (array) ( $body['files'] ?? [] );
		return isset( $files[0]['id'] ) ? (string) $files[0]['id'] : null;
	}

	/**
	 * List every child of a parent folder (non-folders only by default).
	 * Walks paginated `nextPageToken` results until all pages are consumed.
	 *
	 * @param string $parent_id   Drive id to list inside
	 * @param bool   $folders_only When true, returns only folder children (for bk_* discovery)
	 * @return array<int,array{id:string,name:string,size:int,mimeType:string}>
	 */
	public function list_children( string $parent_id, bool $folders_only = false ): array {
		$this->ensure_token();
		$mime_filter = $folders_only
			? " and mimeType = 'application/vnd.google-apps.folder'"
			: " and mimeType != 'application/vnd.google-apps.folder'";
		$query = "'" . $parent_id . "' in parents and trashed = false" . $mime_filter;

		$out   = [];
		$token = '';
		do {
			$params = [
				'q'        => $query,
				'fields'   => 'nextPageToken, files(id,name,size,mimeType)',
				'pageSize' => 100,
			];
			if ( '' !== $token ) {
				$params['pageToken'] = $token;
			}
			$res = wp_remote_get(
				'https://www.googleapis.com/drive/v3/files?' . http_build_query( $params ),
				[ 'timeout' => 30, 'headers' => $this->auth_headers() ]
			);
			if ( is_wp_error( $res ) ) {
				throw new \RuntimeException( esc_html( 'Drive list failed: ' . $res->get_error_message() ) );
			}
			$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
			foreach ( (array) ( $body['files'] ?? [] ) as $f ) {
				$out[] = [
					'id'       => (string) ( $f['id'] ?? '' ),
					'name'     => (string) ( $f['name'] ?? '' ),
					'size'     => (int) ( $f['size'] ?? 0 ),
					'mimeType' => (string) ( $f['mimeType'] ?? '' ),
				];
			}
			$token = (string) ( $body['nextPageToken'] ?? '' );
		} while ( '' !== $token );

		return $out;
	}

	/**
	 * Upload a local file into a Drive folder. Uses resumable upload above
	 * RESUMABLE_THRESHOLD; multipart upload (single request) below.
	 *
	 * Returns the Drive file id. If a file of the same name already exists
	 * in the parent, the upload creates a new revision (Drive's default
	 * behaviour when names collide — Drive doesn't enforce name uniqueness
	 * per folder).
	 */
	public function upload( string $local_path, string $remote_name, string $parent_id ): string {
		$this->ensure_token();
		if ( ! is_readable( $local_path ) ) {
			throw new \RuntimeException( esc_html( "Cannot read local file for Drive upload: {$local_path}" ) );
		}
		$size = (int) filesize( $local_path );

		// Below the threshold → simple multipart upload (one round trip).
		if ( $size <= self::RESUMABLE_THRESHOLD ) {
			return $this->upload_multipart( $local_path, $remote_name, $parent_id );
		}
		return $this->upload_resumable( $local_path, $remote_name, $parent_id, $size );
	}

	/**
	 * Simple multipart upload for small files. One POST with a multipart
	 * body: metadata JSON then the file bytes.
	 */
	private function upload_multipart( string $local_path, string $remote_name, string $parent_id ): string {
		$boundary = wp_generate_password( 32, false, false );
		$metadata = wp_json_encode( [
			'name'    => $remote_name,
			'parents' => [ $parent_id ],
		] );
		$content = "--{$boundary}\r\n"
			. "Content-Type: application/json; charset=UTF-8\r\n\r\n"
			. $metadata . "\r\n"
			. "--{$boundary}\r\n"
			. "Content-Type: application/octet-stream\r\n\r\n"
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem doesn't support binary streaming; the file is small (<5 MB) so memory is fine.
			. (string) file_get_contents( $local_path ) . "\r\n"
			. "--{$boundary}--";
		$res = wp_remote_post( 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', [
			'timeout' => 120,
			'headers' => array_merge( $this->auth_headers(), [
				'Content-Type' => 'multipart/related; boundary=' . $boundary,
			] ),
			'body'    => $content,
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Drive upload failed: ' . $res->get_error_message() ) );
		}
		$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
		if ( wp_remote_retrieve_response_code( $res ) >= 400 || empty( $body['id'] ) ) {
			$msg = $body['error']['message'] ?? 'unknown';
			throw new \RuntimeException( esc_html( "Drive upload failed: {$msg}" ) );
		}
		return (string) $body['id'];
	}

	/**
	 * Resumable upload for files above the threshold. Opens an upload
	 * session, then streams the file in CHUNK_SIZE pieces. Each chunk PUTs
	 * a byte range; Google returns 308 "Resume Incomplete" until the final
	 * chunk lands with 200/201.
	 *
	 * Why streamed: a 500 MB zip part would blow memory if we loaded the
	 * whole thing. fread/fseek in chunk-sized windows keeps memory bounded.
	 */
	private function upload_resumable( string $local_path, string $remote_name, string $parent_id, int $size ): string {
		// Step 1: initiate session — Google returns a Location URL we PUT bytes to.
		$metadata = wp_json_encode( [
			'name'    => $remote_name,
			'parents' => [ $parent_id ],
		] );
		$init = wp_remote_post( 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable', [
			'timeout' => 30,
			'headers' => array_merge( $this->auth_headers(), [
				'Content-Type'             => 'application/json; charset=UTF-8',
				'X-Upload-Content-Type'    => 'application/octet-stream',
				'X-Upload-Content-Length'  => (string) $size,
			] ),
			'body'    => $metadata,
		] );
		if ( is_wp_error( $init ) ) {
			throw new \RuntimeException( esc_html( 'Drive resumable init failed: ' . $init->get_error_message() ) );
		}
		if ( wp_remote_retrieve_response_code( $init ) !== 200 ) {
			$body = json_decode( (string) wp_remote_retrieve_body( $init ), true );
			$msg  = $body['error']['message'] ?? wp_remote_retrieve_response_message( $init );
			throw new \RuntimeException( esc_html( "Drive resumable init failed: {$msg}" ) );
		}
		$session_uri = (string) wp_remote_retrieve_header( $init, 'location' );
		if ( '' === $session_uri ) {
			throw new \RuntimeException( esc_html( 'Drive resumable init: no Location header returned.' ) );
		}

		// Step 2: stream the file to the session URI in CHUNK_SIZE pieces.
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem doesn't support streaming reads of GB-scale files.
		$fh = @fopen( $local_path, 'rb' );
		if ( ! $fh ) {
			throw new \RuntimeException( esc_html( "Cannot open file for Drive upload: {$local_path}" ) );
		}

		$offset = 0;
		try {
			while ( $offset < $size ) {
				$chunk_end  = min( $offset + self::CHUNK_SIZE, $size ) - 1;
				$chunk_size = $chunk_end - $offset + 1;
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- WP_Filesystem doesn't support streaming reads.
				$chunk = (string) fread( $fh, $chunk_size );
				if ( strlen( $chunk ) !== $chunk_size ) {
					throw new \RuntimeException( esc_html( 'Short read mid-upload; aborting Drive session.' ) );
				}

				$res = wp_remote_request( $session_uri, [
					'method'  => 'PUT',
					'timeout' => 300,
					'headers' => [
						// No Authorization header here — the session URI carries its own credentials.
						'Content-Length' => (string) $chunk_size,
						'Content-Range'  => "bytes {$offset}-{$chunk_end}/{$size}",
					],
					'body'    => $chunk,
				] );
				if ( is_wp_error( $res ) ) {
					throw new \RuntimeException( esc_html( 'Drive chunk upload failed: ' . $res->get_error_message() ) );
				}
				$code = wp_remote_retrieve_response_code( $res );
				if ( 308 === $code ) {
					// Resume Incomplete — server received the chunk, expects more.
					$offset = $chunk_end + 1;
					continue;
				}
				if ( in_array( $code, [ 200, 201 ], true ) ) {
					// Final chunk — server returns the file metadata.
					$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
					if ( empty( $body['id'] ) ) {
						throw new \RuntimeException( esc_html( 'Drive upload completed but no file id returned.' ) );
					}
					return (string) $body['id'];
				}
				// Anything else is an error.
				$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
				$msg  = $body['error']['message'] ?? "HTTP {$code}";
				throw new \RuntimeException( esc_html( "Drive chunk upload failed: {$msg}" ) );
			}
		} finally {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streamed-read counterpart to fopen above.
			@fclose( $fh );
		}
		throw new \RuntimeException( esc_html( 'Drive upload loop finished without final response.' ) );
	}

	/**
	 * Download a Drive file's bytes into a local path. Streams the response
	 * to disk via a temp file so we don't load the whole file into memory —
	 * the `stream` filter on wp_remote_request enables this for HTTP API
	 * backends that support it (cURL does; the streams fallback does too
	 * since WP 5.4).
	 */
	public function download_file( string $file_id, string $local_path ): bool {
		$this->ensure_token();
		$res = wp_remote_get( "https://www.googleapis.com/drive/v3/files/{$file_id}?alt=media", [
			'timeout'  => 600,
			'headers'  => $this->auth_headers(),
			'stream'   => true,
			'filename' => $local_path,
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'Drive download failed: ' . $res->get_error_message() ) );
		}
		if ( wp_remote_retrieve_response_code( $res ) >= 400 ) {
			// On stream-mode error, the body is in the file — drop it.
			if ( is_file( $local_path ) ) {
				wp_delete_file( $local_path );
			}
			throw new \RuntimeException( esc_html( 'Drive download returned ' . wp_remote_retrieve_response_code( $res ) ) );
		}
		return is_file( $local_path ) && filesize( $local_path ) > 0;
	}

	/**
	 * Delete a file (move to trash; Drive deletes permanently after 30 days).
	 */
	public function delete_file( string $file_id ): bool {
		$this->ensure_token();
		$res = wp_remote_request( "https://www.googleapis.com/drive/v3/files/{$file_id}", [
			'method'  => 'DELETE',
			'timeout' => 15,
			'headers' => $this->auth_headers(),
		] );
		if ( is_wp_error( $res ) ) {
			return false;
		}
		$code = wp_remote_retrieve_response_code( $res );
		// 204 = success. 404 = already gone (treat as success too).
		return 204 === $code || 404 === $code;
	}
}
