<?php
/**
 * File: CdnEngine_Azure_MI_Utility.php
 *
 * Microsoft Azure Managed Identities are available only for services running on Azure when a "system assigned" identity is enabled.
 *
 * A system assigned managed identity is restricted to one per resource and is tied to the lifecycle of a resource.
 * You can grant permissions to the managed identity by using Azure role-based access control (Azure RBAC).
 * The managed identity is authenticated with Microsoft Entra ID, so you don’t have to store any credentials in code.
 *
 * @package W3TC
 * @since   2.7.7
 */

namespace W3TC;

/**
 * Class: CdnEngine_Azure_MI_Utility
 *
 * This class defines utility functions for Azure blob storage access using Managed Identity.
 *
 * @since   2.8.0
 * @author  Zubair <zmohammed@microsoft.com>
 * @author  BoldGrid <development@boldgrid.com>
 *
 * phpcs:disable WordPress.WP.AlternativeFunctions
 */
class CdnEngine_Azure_MI_Utility {
	/**
	 * Entra API version.
	 *
	 * @since 2.7.7
	 *
	 * @var string
	 */
	const ENTRA_API_VERSION = '2019-08-01';

	/**
	 * Entra resource URI.
	 *
	 * @since 2.7.7
	 *
	 * @var string
	 */
	const ENTRA_RESOURCE_URI = 'https://storage.azure.com';

	/**
	 * Blob API version.
	 *
	 * @since 2.7.7
	 *
	 * @var string
	 */
	const BLOB_API_VERSION = '2020-10-02';

	/**
	 * Retrieves an access token from the Managed Identity endpoint.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @return string $w3tc_access_token
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function get_access_token( string $entra_client_id ): string {
		// Get environment variables.
		$identity_header   = \getenv( 'IDENTITY_HEADER' );
		$identity_endpoint = \getenv( 'IDENTITY_ENDPOINT' );

		// Validate variables.
		if ( empty( $identity_endpoint ) || empty( $identity_header ) || empty( $entra_client_id ) ) {
			throw new \RuntimeException( \esc_html__( 'Error: get_access_token - missing required environment variables.', 'w3-total-cache' ) );
		}

		// Construct URL for cURL request.
		$w3tc_url = $identity_endpoint . '?' . http_build_query(
			array(
				'api-version' => self::ENTRA_API_VERSION,
				'resource'    => self::ENTRA_RESOURCE_URI,
				'client_id'   => $entra_client_id,
			)
		);

		// Initialize and execute cURL request.
		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, $w3tc_url );
		\curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'GET' );
		\curl_setopt( $ch, CURLOPT_HTTPHEADER, array( 'X-IDENTITY-HEADER: ' . $identity_header ) );
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: get_access_token - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 200 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: get_access_token - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}
		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: get_access_token - invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}

		// Parse JSON response and extract access_token.
		$json_response = \json_decode( $response, true );

		if ( \json_last_error() !== JSON_ERROR_NONE ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 JSON last error message.
						\__( 'Error: get_access_token - failed to parse the JSON response: %1$s', 'w3-total-cache' ),
						\json_last_error_msg()
					)
				)
			);
		}

		if ( empty( $json_response['access_token'] ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: get_access_token - no token found in response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}

		return $json_response['access_token'];
	}

	/**
	 * Get Azure Blob Storage blob properties.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @param string $storage_account Storage account name.
	 * @param string $container_id    Container ID.
	 * @param string $blob             Blob ID.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function get_blob_properties( string $entra_client_id, string $storage_account, string $container_id, $blob ): array {
		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/' . $container_id . '/' . $blob );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt(
			$ch,
			CURLOPT_HTTPHEADER,
			array(
				'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
				'x-ms-version: ' . self::BLOB_API_VERSION,
				'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', time() ),
			)
		);
		\curl_setopt( $ch, CURLOPT_HEADER, true );
		\curl_setopt( $ch, CURLOPT_NOBODY, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: get_blob_properties - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 200 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: get_blob_properties - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: get_blob_properties - invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}

		return self::parse_header( $response );
	}

	/**
	 * Create block blob.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @param string $storage_account Storage account name.
	 * @param string $container_id    Container ID.
	 * @param string $blob            Blob ID.
	 * @param mixed  $contents        Contents.
	 * @param string $content_type    Content type.
	 * @param string $content_md5     Content MD5 hash.
	 * @param string $cache_control   Cache control header value.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function create_block_blob(
		string $entra_client_id,
		string $storage_account,
		string $container_id,
		string $blob,
		$contents,
		?string $content_type = null,
		?string $content_md5 = null,
		?string $cache_control = null
	): array {
		$headers = array(
			'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
			'x-ms-version: ' . self::BLOB_API_VERSION,
			'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', \time() ),
			'x-ms-blob-type: BlockBlob',
		);

		if ( $content_type ) {
			$headers[] = 'x-ms-blob-content-type: ' . $content_type;
		}
		if ( $content_md5 ) {
			$headers[] = 'x-ms-blob-content-md5: ' . $content_md5;
		}
		if ( $cache_control ) {
			$headers[] = 'x-ms-blob-cache-control: ' . $cache_control;
		}

		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/' . $container_id . '/' . $blob );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'PUT' );
		\curl_setopt( $ch, CURLOPT_POSTFIELDS, $contents );
		\curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt( $ch, CURLOPT_HEADER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: create_block_blob - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 201 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: create_block_blob - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: create_block_blob - invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}
		return self::parse_header( $response );
	}

	/**
	 * Delete blob.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @param string $storage_account Storage account name.
	 * @param string $container_id    Container ID.
	 * @param string $blob            Blob ID.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function delete_blob( string $entra_client_id, string $storage_account, string $container_id, string $blob ) {
		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/' . $container_id . '/' . $blob );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'DELETE' );
		\curl_setopt(
			$ch,
			CURLOPT_HTTPHEADER,
			array(
				'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
				'x-ms-version: ' . self::BLOB_API_VERSION,
				'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', \time() ),
			)
		);
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt( $ch, CURLOPT_HEADER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: delete_blob - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		curl_close( $ch );

		if ( 202 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: delete_blob - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: delete_blob - invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}
		return self::parse_header( $response );
	}

	/**
	 * Create an Azure Blob Storage container/bucket.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id    Entra ID.
	 * @param string $storage_account    Storage account name.
	 * @param string $container_id       Container ID.
	 * @param string $public_access_type Public access type.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function create_container(
		string $entra_client_id,
		string $storage_account,
		string $container_id,
		string $public_access_type = 'blob'
	): array {
		$headers = array(
			'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
			'x-ms-version: ' . self::BLOB_API_VERSION,
			'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', \time() ),
			'Content-Length: 0',
		);

		if ( $public_access_type ) {
			$headers[] = "x-ms-blob-public-access: $public_access_type";
		}

		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/' . $container_id . '?restype=container' );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt( $ch, CURLOPT_CUSTOMREQUEST, 'PUT' );
		\curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers );
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt( $ch, CURLOPT_HEADER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: create_container - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 201 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: create_container - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException( \esc_html__( 'Error: create_container - empty response.', 'w3-total-cache' ) );
		}
		return self::parse_header( $response );
	}

	/**
	 * List Azure Blob Storage containers.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @param string $storage_account Storage account name.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function list_containers( string $entra_client_id, string $storage_account ): array {
		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/?comp=list' );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt(
			$ch,
			CURLOPT_HTTPHEADER,
			array(
				'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
				'x-ms-version: ' . self::BLOB_API_VERSION,
				'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', \time() ),
			)
		);
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: list_containers - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$http_code = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 200 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: list_containers - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: list_containers - Invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}

		// Parse XML response to array (with XXE protections).
		$xml      = Util_Environment::safe_simplexml_load_string( $response );
		$json     = \json_encode( $xml );
		$response = \json_decode( $json, true );

		$array_response = array();

		if ( ! empty( $response['Containers']['Container'] ) ) {
			$array_response = self::get_array( $response['Containers']['Container'] );
		}

		return $array_response;
	}

	/**
	 * Get blob.
	 *
	 * @since 2.7.7
	 *
	 * @param string $entra_client_id Entra ID.
	 * @param string $storage_account Storage account name.
	 * @param string $container_id    Container ID.
	 * @param string $blob            Blob ID.
	 * @return array
	 * @throws \RuntimeException Runtine Exception.
	 */
	public static function get_blob( string $entra_client_id, string $storage_account, string $container_id, string $blob ): array {
		$ch = \curl_init();

		\curl_setopt( $ch, CURLOPT_URL, 'https://' . $storage_account . '.blob.core.windows.net/' . $container_id . '/' . $blob );
		\curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true );
		\curl_setopt(
			$ch,
			CURLOPT_HTTPHEADER,
			array(
				'Authorization: Bearer ' . self::get_access_token( $entra_client_id ),
				'x-ms-version: ' . self::BLOB_API_VERSION,
				'x-ms-date: ' . \gmdate( 'D, d M Y H:i:s T', \time() ),
			)
		);
		\curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER, true );
		\curl_setopt( $ch, CURLOPT_HEADER, true );

		$response = \curl_exec( $ch );

		if ( \curl_errno( $ch ) ) {
			$error = \curl_error( $ch );
			\curl_close( $ch );
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Error message.
						\__( 'Error: get_blob - cURL request failed: %1$s', 'w3-total-cache' ),
						$error
					)
				)
			);
		}

		$header_size = \curl_getinfo( $ch, CURLINFO_HEADER_SIZE );
		$http_code   = \curl_getinfo( $ch, CURLINFO_HTTP_CODE );
		\curl_close( $ch );

		if ( 200 !== $http_code ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 HTTP status code.
						\__( 'Error: get_blob - HTTP request failed with status code: %1$s', 'w3-total-cache' ),
						$http_code
					)
				)
			);
		}

		if ( empty( $response ) ) {
			throw new \RuntimeException(
				\esc_html(
					sprintf(
						// Translators: 1 Response.
						\__( 'Error: get_blob - Invalid response data: %1$s', 'w3-total-cache' ),
						$response
					)
				)
			);
		}

		return array(
			'headers' => self::parse_header( \substr( $response, 0, $header_size ) ),
			'data'    => \substr( $response, $header_size ),
		);
	}

	/**
	 * Get array.
	 *
	 * @since  2.7.7
	 * @access private
	 *
	 * @param mixed $input Variable used to get array.
	 *
	 * @return array
	 */
	private static function get_array( $input ): array {
		if ( empty( $input ) || ! \is_array( $input ) ) {
			return array();
		}

		foreach ( $input as $w3tc_value ) {
			if ( ! \is_array( $w3tc_value ) ) {
				return array( $input );
			}

			return $input;
		}
	}

	/**
	 * Parse header from string to array.
	 *
	 * @since  2.7.7
	 * @access private
	 *
	 * @param string $header Header.
	 * @return array
	 */
	private static function parse_header( string $header ): array {
		$headers      = array();
		$header_text  = \substr( $header, 0, \strpos( $header, "\r\n\r\n" ) );
		$header_parts = \explode( "\r\n", $header_text );

		foreach ( $header_parts as $header ) {
			if ( \strpos( $header, ':' ) !== false ) {
				$header_parts                = \explode( ':', $header );
				$headers[ $header_parts[0] ] = \trim( $header_parts[1] );
			}
		}

		return $headers;
	}
}
