<?php
/**
 * OneDrive HTTP client (Microsoft Graph v1.0).
 *
 * Talks to Graph directly via wp_remote_* — no Microsoft SDK bundle.
 * Same shape as our GoogleClient and S3Client. Microsoft Graph is more
 * forgiving than Drive in that paths are first-class (no need to resolve
 * names → ids on every call), so the code is a little shorter.
 *
 * Endpoint roots:
 *   me/drive/root:/<path>:/<action>     — path-based addressing
 *   me/drive/items/<id>                 — id-based addressing (for delete)
 *
 * @package Backreload\Destinations
 */

namespace Backreload\Destinations;

use Backreload\Models\DestinationRecord;

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

class OnedriveClient {

	/**
	 * Below this size Graph supports a single-PUT upload to
	 * `root:/<path>:/content`. Above it we have to open an upload session and
	 * stream chunks. Microsoft's documented cutoff is 4 MB.
	 */
	const SIMPLE_UPLOAD_LIMIT = 4 * 1048576;

	/**
	 * Chunk size for resumable uploads. Microsoft requires chunks to be
	 * multiples of 320 KiB (327,680 bytes); they recommend 5–10 MiB for
	 * "best performance". We use 5 MiB which is 5,242,880 = 16 × 320 KiB.
	 *
	 * Note: this is the request body size, NOT the upload session lifetime
	 * (sessions live for ~hours; individual chunk PUTs have their own
	 * timeout).
	 */
	const CHUNK_SIZE = 5 * 1048576;

	const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';

	private DestinationRecord $dest;

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

	/**
	 * Refresh the access token if expired. Mirrors GoogleClient::ensure_token
	 * but hits Microsoft's identity endpoint and uses the destination's
	 * configured tenant (default `common` for personal + workplace combined).
	 */
	public function ensure_token(): void {
		$expires_at = (int) ( $this->dest->config['expires_at'] ?? 0 );
		if ( $expires_at > time() + 30 ) {
			return;
		}
		$refresh = (string) ( $this->dest->config['refresh_token'] ?? '' );
		if ( '' === $refresh ) {
			throw new \RuntimeException( esc_html( 'OneDrive access token expired and no refresh token is stored. Re-authorize the destination from the Destinations page.' ) );
		}
		$tenant   = (string) ( $this->dest->config['tenant_id'] ?? 'common' );
		$token_url = "https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token";

		$res = wp_remote_post( $token_url, [
			'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',
				// Re-request the same scopes we got on initial consent so the
				// new access token has the same capabilities.
				'scope'         => 'offline_access Files.ReadWrite User.Read',
			],
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'OneDrive 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( "OneDrive 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 );
		if ( ! empty( $body['refresh_token'] ) ) {
			$this->dest->config['refresh_token'] = (string) $body['refresh_token'];
		}
		$this->dest->save();
	}

	private function auth_headers(): array {
		return [ 'Authorization' => 'Bearer ' . (string) $this->dest->config['access_token'] ];
	}

	/**
	 * URL-encode the path segments of a Graph path expression. Slashes are
	 * preserved (they delimit folders), but characters like spaces, +, &
	 * need to be percent-encoded or Graph rejects the request.
	 *
	 * `rawurlencode` doesn't encode `/`, so we apply it per segment.
	 */
	private function encode_path( string $path ): string {
		return implode( '/', array_map( 'rawurlencode', explode( '/', ltrim( $path, '/' ) ) ) );
	}

	/**
	 * Hit /me/drive and return true on success. Used by the destination's
	 * test() method.
	 */
	public function test_connection(): bool {
		$this->ensure_token();
		$res = wp_remote_get( self::GRAPH_BASE . '/me/drive', [
			'timeout' => 15,
			'headers' => $this->auth_headers(),
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'OneDrive 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( "OneDrive rejected the request: {$msg}" ) );
		}
		return true;
	}

	/**
	 * Read drive quota from /me/drive. Returns bytes used / total / free.
	 *
	 * @return array{used:int,total:int,free:int}
	 */
	public function quota(): array {
		$this->ensure_token();
		$res = wp_remote_get( self::GRAPH_BASE . '/me/drive', [
			'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['quota']['used'] ?? 0 );
		$total = (int) ( $body['quota']['total'] ?? 0 );
		$free  = (int) ( $body['quota']['remaining'] ?? max( 0, $total - $used ) );
		return [ 'used' => $used, 'total' => $total, 'free' => $free ];
	}

	/**
	 * Make sure a folder path exists in the drive, creating any missing
	 * segments. Graph supports `conflictBehavior:fail|rename|replace` on
	 * create; we use `fail` so a parallel writer doesn't accidentally
	 * clobber an existing folder, then swallow the 409 if it already exists.
	 *
	 * Idempotent — safe to call before every upload.
	 */
	public function ensure_folder( string $path ): void {
		$this->ensure_token();
		$path = trim( $path, '/' );
		if ( '' === $path ) {
			return; // root always exists
		}
		// Walk path segment by segment so each level is created in order.
		$segments = explode( '/', $path );
		$parent   = '';
		foreach ( $segments as $seg ) {
			$parent_url = '' === $parent
				? self::GRAPH_BASE . '/me/drive/root/children'
				: self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $parent ) . ':/children';

			$res = wp_remote_post( $parent_url, [
				'timeout' => 20,
				'headers' => array_merge( $this->auth_headers(), [
					'Content-Type' => 'application/json',
				] ),
				'body'    => wp_json_encode( [
					'name'                              => $seg,
					'folder'                            => (object) [],
					'@microsoft.graph.conflictBehavior' => 'fail',
				] ),
			] );
			if ( is_wp_error( $res ) ) {
				throw new \RuntimeException( esc_html( 'OneDrive folder create failed: ' . $res->get_error_message() ) );
			}
			$code = wp_remote_retrieve_response_code( $res );
			// 201 Created = made it. 409 Conflict = already there (fine).
			if ( ! in_array( $code, [ 201, 409 ], true ) ) {
				$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
				$msg  = $body['error']['message'] ?? "HTTP {$code}";
				throw new \RuntimeException( esc_html( "OneDrive folder create failed: {$msg}" ) );
			}
			$parent = '' === $parent ? $seg : ( $parent . '/' . $seg );
		}
	}

	/**
	 * Upload a local file to `<remote_path>` (a path inside the drive root).
	 * Routes to either single-PUT (small files) or upload session (large
	 * files) based on size.
	 */
	public function upload( string $local_path, string $remote_path ): void {
		$this->ensure_token();
		if ( ! is_readable( $local_path ) ) {
			throw new \RuntimeException( esc_html( "Cannot read local file for OneDrive upload: {$local_path}" ) );
		}
		$size = (int) filesize( $local_path );
		if ( $size <= self::SIMPLE_UPLOAD_LIMIT ) {
			$this->upload_simple( $local_path, $remote_path );
			return;
		}
		$this->upload_session( $local_path, $remote_path, $size );
	}

	/**
	 * Single-PUT upload for small files. Graph treats this as overwrite-on-
	 * conflict by default, which matches the engine's "re-upload should
	 * replace" semantics.
	 */
	private function upload_simple( string $local_path, string $remote_path ): void {
		$url = self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $remote_path ) . ':/content';
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem doesn't support binary; file is <4 MB so memory is bounded.
		$body = (string) file_get_contents( $local_path );
		$res  = wp_remote_request( $url, [
			'method'  => 'PUT',
			'timeout' => 120,
			'headers' => array_merge( $this->auth_headers(), [
				'Content-Type' => 'application/octet-stream',
			] ),
			'body'    => $body,
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'OneDrive upload failed: ' . $res->get_error_message() ) );
		}
		$code = wp_remote_retrieve_response_code( $res );
		if ( $code >= 400 ) {
			$body_json = json_decode( (string) wp_remote_retrieve_body( $res ), true );
			$msg       = $body_json['error']['message'] ?? "HTTP {$code}";
			throw new \RuntimeException( esc_html( "OneDrive upload failed: {$msg}" ) );
		}
	}

	/**
	 * Resumable upload via upload session. Graph returns an `uploadUrl`
	 * we PUT bytes to in chunks. The server tracks the byte range; we send
	 * a Content-Range header on each chunk telling it where the chunk fits.
	 *
	 * The conflictBehavior is set on session creation (not per chunk).
	 */
	private function upload_session( string $local_path, string $remote_path, int $size ): void {
		// Step 1: create upload session.
		$init_url = self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $remote_path ) . ':/createUploadSession';
		$init     = wp_remote_post( $init_url, [
			'timeout' => 30,
			'headers' => array_merge( $this->auth_headers(), [
				'Content-Type' => 'application/json',
			] ),
			'body'    => wp_json_encode( [
				'item' => [
					'@microsoft.graph.conflictBehavior' => 'replace',
				],
			] ),
		] );
		if ( is_wp_error( $init ) ) {
			throw new \RuntimeException( esc_html( 'OneDrive session init failed: ' . $init->get_error_message() ) );
		}
		if ( wp_remote_retrieve_response_code( $init ) >= 400 ) {
			$body = json_decode( (string) wp_remote_retrieve_body( $init ), true );
			$msg  = $body['error']['message'] ?? 'unknown';
			throw new \RuntimeException( esc_html( "OneDrive session init failed: {$msg}" ) );
		}
		$body        = json_decode( (string) wp_remote_retrieve_body( $init ), true );
		$session_uri = (string) ( $body['uploadUrl'] ?? '' );
		if ( '' === $session_uri ) {
			throw new \RuntimeException( esc_html( 'OneDrive session init: no uploadUrl returned.' ) );
		}

		// Step 2: stream chunks to the session URL.
		// 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 OneDrive 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 OneDrive session.' ) );
				}
				$res = wp_remote_request( $session_uri, [
					'method'  => 'PUT',
					'timeout' => 300,
					'headers' => [
						// No Authorization — session URL is pre-authorized.
						'Content-Length' => (string) $chunk_size,
						'Content-Range'  => "bytes {$offset}-{$chunk_end}/{$size}",
					],
					'body'    => $chunk,
				] );
				if ( is_wp_error( $res ) ) {
					throw new \RuntimeException( esc_html( 'OneDrive chunk upload failed: ' . $res->get_error_message() ) );
				}
				$code = wp_remote_retrieve_response_code( $res );
				if ( in_array( $code, [ 200, 201, 202 ], true ) ) {
					// 202 = more chunks expected, 200/201 = final chunk landed.
					if ( in_array( $code, [ 200, 201 ], true ) ) {
						return;
					}
					$offset = $chunk_end + 1;
					continue;
				}
				$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
				$msg  = $body['error']['message'] ?? "HTTP {$code}";
				throw new \RuntimeException( esc_html( "OneDrive chunk upload failed: {$msg}" ) );
			}
		} finally {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- streamed-read counterpart to fopen above.
			@fclose( $fh );
		}
	}

	/**
	 * Download a file by drive-path to a local file. Streams via WP's HTTP
	 * API stream-to-file mode so memory stays bounded for large parts.
	 */
	public function download( string $remote_path, string $local_path ): bool {
		$this->ensure_token();
		$url = self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $remote_path ) . ':/content';
		$res = wp_remote_get( $url, [
			'timeout'  => 600,
			'headers'  => $this->auth_headers(),
			'stream'   => true,
			'filename' => $local_path,
		] );
		if ( is_wp_error( $res ) ) {
			throw new \RuntimeException( esc_html( 'OneDrive download failed: ' . $res->get_error_message() ) );
		}
		if ( wp_remote_retrieve_response_code( $res ) >= 400 ) {
			if ( is_file( $local_path ) ) {
				wp_delete_file( $local_path );
			}
			return false;
		}
		return is_file( $local_path ) && filesize( $local_path ) > 0;
	}

	/**
	 * Delete a file by drive-path. Returns true on success or if the file
	 * was already missing.
	 */
	public function delete( string $remote_path ): bool {
		$this->ensure_token();
		$url = self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $remote_path );
		$res = wp_remote_request( $url, [
			'method'  => 'DELETE',
			'timeout' => 30,
			'headers' => $this->auth_headers(),
		] );
		if ( is_wp_error( $res ) ) {
			return false;
		}
		$code = wp_remote_retrieve_response_code( $res );
		return 204 === $code || 404 === $code;
	}

	/**
	 * List the immediate children of a folder path. Returns one entry per
	 * child with name + size + folder flag. Walks @odata.nextLink for
	 * pagination so folders with hundreds of items still come back complete.
	 *
	 * @return array<int,array{name:string,size:int,is_folder:bool}>
	 */
	public function list_children( string $folder_path ): array {
		$this->ensure_token();
		$folder_path = trim( $folder_path, '/' );
		$url = '' === $folder_path
			? self::GRAPH_BASE . '/me/drive/root/children'
			: self::GRAPH_BASE . '/me/drive/root:/' . $this->encode_path( $folder_path ) . ':/children';

		$out = [];
		while ( $url ) {
			$res = wp_remote_get( $url, [
				'timeout' => 30,
				'headers' => $this->auth_headers(),
			] );
			if ( is_wp_error( $res ) ) {
				throw new \RuntimeException( esc_html( 'OneDrive list failed: ' . $res->get_error_message() ) );
			}
			if ( wp_remote_retrieve_response_code( $res ) === 404 ) {
				return []; // folder doesn't exist yet — return empty rather than throwing
			}
			if ( wp_remote_retrieve_response_code( $res ) >= 400 ) {
				$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
				$msg  = $body['error']['message'] ?? 'unknown';
				throw new \RuntimeException( esc_html( "OneDrive list failed: {$msg}" ) );
			}
			$body = json_decode( (string) wp_remote_retrieve_body( $res ), true );
			foreach ( (array) ( $body['value'] ?? [] ) as $item ) {
				$out[] = [
					'name'      => (string) ( $item['name'] ?? '' ),
					'size'      => (int) ( $item['size'] ?? 0 ),
					'is_folder' => isset( $item['folder'] ),
				];
			}
			$url = (string) ( $body['@odata.nextLink'] ?? '' );
		}
		return $out;
	}
}
