<?php
/**
 * Virtual HTTP service client
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

/**
 * Virtual HTTP service client loosely styled after a Virtual File System
 *
 * Services can be mounted on path prefixes so that virtual HTTP operations
 * against sub-paths will map to those services. Operations can actually be
 * done using HTTP messages over the wire or may simple be emulated locally.
 *
 * Virtual HTTP request maps are arrays that use the following format:
 *   - method   : GET/HEAD/PUT/POST/DELETE
 *   - url      : HTTP/HTTPS URL or virtual service path with a registered prefix
 *   - query    : <query parameter field/value associative array> (uses RFC 3986)
 *   - headers  : <header name/value associative array>
 *   - body     : source to get the HTTP request body from;
 *                this can simply be a string (always), a resource for
 *                PUT requests, and a field/value array for POST request;
 *                array bodies are encoded as multipart/form-data and strings
 *                use application/x-www-form-urlencoded (headers sent automatically)
 *   - stream   : resource to stream the HTTP response body to
 * Request maps can use integer index 0 instead of 'method' and 1 instead of 'url'.
 *
 * @since 1.23
 */
class VirtualRESTServiceClient {
	/** @var MultiHttpClient */
	private $http;
	/** @var array Map of (prefix => VirtualRESTService|array) */
	private $instances = [];

	private const VALID_MOUNT_REGEX = '#^/[0-9a-z]+/([0-9a-z]+/)*$#';

	/**
	 * @param MultiHttpClient $http
	 */
	public function __construct( MultiHttpClient $http ) {
		$this->http = $http;
	}

	/**
	 * Map a prefix to service handler
	 *
	 * If $instance is in array, it must have these keys:
	 *   - class : string; fully qualified VirtualRESTService class name
	 *   - config : array; map of parameters that is the first __construct() argument
	 *
	 * @param string $prefix Virtual path
	 * @param VirtualRESTService|array $instance Service or info to yield the service
	 */
	public function mount( $prefix, $instance ) {
		if ( !preg_match( self::VALID_MOUNT_REGEX, $prefix ) ) {
			throw new UnexpectedValueException( "Invalid service mount point '$prefix'." );
		} elseif ( isset( $this->instances[$prefix] ) ) {
			throw new UnexpectedValueException( "A service is already mounted on '$prefix'." );
		}
		if ( !( $instance instanceof VirtualRESTService ) ) {
			if ( !isset( $instance['class'] ) || !isset( $instance['config'] ) ) {
				throw new UnexpectedValueException( "Missing 'class' or 'config' ('$prefix')." );
			}
		}
		$this->instances[$prefix] = $instance;
	}

	/**
	 * Unmap a prefix to service handler
	 *
	 * @param string $prefix Virtual path
	 */
	public function unmount( $prefix ) {
		if ( !preg_match( self::VALID_MOUNT_REGEX, $prefix ) ) {
			throw new UnexpectedValueException( "Invalid service mount point '$prefix'." );
		} elseif ( !isset( $this->instances[$prefix] ) ) {
			throw new UnexpectedValueException( "No service is mounted on '$prefix'." );
		}
		unset( $this->instances[$prefix] );
	}

	/**
	 * Get the prefix and service that a virtual path is serviced by
	 *
	 * @param string $path
	 * @return array (prefix,VirtualRESTService) or (null,null) if none found
	 */
	public function getMountAndService( $path ) {
		$cmpFunc = static function ( $a, $b ) {
			$al = substr_count( $a, '/' );
			$bl = substr_count( $b, '/' );
			return $bl <=> $al; // largest prefix first
		};

		$matches = []; // matching prefixes (mount points)
		foreach ( $this->instances as $prefix => $unused ) {
			if ( strpos( $path, $prefix ) === 0 ) {
				$matches[] = $prefix;
			}
		}
		usort( $matches, $cmpFunc );

		// Return the most specific prefix and corresponding service
		return $matches
			? [ $matches[0], $this->getInstance( $matches[0] ) ]
			: [ null, null ];
	}

	/**
	 * Execute a virtual HTTP(S) request
	 *
	 * This method returns a response map of:
	 *   - code    : HTTP response code or 0 if there was a serious cURL error
	 *   - reason  : HTTP response reason (empty if there was a serious cURL error)
	 *   - headers : <header name/value associative array>
	 *   - body    : HTTP response body or resource (if "stream" was set)
	 *   - error   : Any cURL error string
	 * The map also stores integer-indexed copies of these values. This lets callers do:
	 * @code
	 *     [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $client->run( $req );
	 * @endcode
	 * @param array $req Virtual HTTP request maps
	 * @return array Response array for request
	 */
	public function run( array $req ) {
		return $this->runMulti( [ $req ] )[0];
	}

	/**
	 * Execute a set of virtual HTTP(S) requests concurrently
	 *
	 * A map of requests keys to response maps is returned. Each response map has:
	 *   - code    : HTTP response code or 0 if there was a serious cURL error
	 *   - reason  : HTTP response reason (empty if there was a serious cURL error)
	 *   - headers : <header name/value associative array>
	 *   - body    : HTTP response body or resource (if "stream" was set)
	 *   - error   : Any cURL error string
	 * The map also stores integer-indexed copies of these values. This lets callers do:
	 * @code
	 *     [ $rcode, $rdesc, $rhdrs, $rbody, $rerr ] = $responses[0];
	 * @endcode
	 *
	 * @param array[] $reqs Map of Virtual HTTP request maps
	 * @return array $reqs Map of corresponding response values with the same keys/order
	 * @throws Exception
	 */
	public function runMulti( array $reqs ) {
		foreach ( $reqs as $index => &$req ) {
			if ( isset( $req[0] ) ) {
				$req['method'] = $req[0]; // short-form
				unset( $req[0] );
			}
			if ( isset( $req[1] ) ) {
				$req['url'] = $req[1]; // short-form
				unset( $req[1] );
			}
			$req['chain'] = []; // chain or list of replaced requests
		}
		unset( $req ); // don't assign over this by accident

		$curUniqueId = 0;
		$armoredIndexMap = []; // (original index => new index)

		$doneReqs = []; // (index => request)
		$executeReqs = []; // (index => request)
		$replaceReqsByService = []; // (prefix => index => request)
		$origPending = []; // (index => 1) for original requests

		foreach ( $reqs as $origIndex => $req ) {
			// Re-index keys to consecutive integers (they will be swapped back later)
			$index = $curUniqueId++;
			$armoredIndexMap[$origIndex] = $index;
			$origPending[$index] = 1;
			if ( preg_match( '#^(http|ftp)s?://#', $req['url'] ) ) {
				// Absolute FTP/HTTP(S) URL, run it as normal
				$executeReqs[$index] = $req;
			} else {
				// Must be a virtual HTTP URL; resolve it
				[ $prefix, $service ] = $this->getMountAndService( $req['url'] );
				if ( !$service ) {
					throw new UnexpectedValueException( "Path '{$req['url']}' has no service." );
				}
				// Set the URL to the mount-relative portion
				$req['url'] = substr( $req['url'], strlen( $prefix ) );
				$replaceReqsByService[$prefix][$index] = $req;
			}
		}

		// Function to get IDs that won't collide with keys in $armoredIndexMap
		$idFunc = static function () use ( &$curUniqueId ) {
			return $curUniqueId++;
		};

		$rounds = 0;
		do {
			if ( ++$rounds > 5 ) {
				throw new Exception( "Too many replacement rounds detected. Aborting." );
			}
			// Track requests executed this round that have a prefix/service.
			// Note that this also includes requests where 'response' was forced.
			$checkReqIndexesByPrefix = [];
			// Resolve the virtual URLs valid and qualified HTTP(S) URLs
			// and add any required authentication headers for the backend.
			// Services can also replace requests with new ones, either to
			// defer the original or to set a proxy response to the original.
			$newReplaceReqsByService = [];
			foreach ( $replaceReqsByService as $prefix => $servReqs ) {
				$service = $this->getInstance( $prefix );
				foreach ( $service->onRequests( $servReqs, $idFunc ) as $index => $req ) {
					// Services use unique IDs for replacement requests
					if ( isset( $servReqs[$index] ) || isset( $origPending[$index] ) ) {
						// A current or original request which was not modified
					} else {
						// Replacement request that will convert to original requests
						$newReplaceReqsByService[$prefix][$index] = $req;
					}
					if ( isset( $req['response'] ) ) {
						// Replacement requests with pre-set responses should not execute
						unset( $executeReqs[$index] );
						unset( $origPending[$index] );
						$doneReqs[$index] = $req;
					} else {
						// Original or mangled request included
						$executeReqs[$index] = $req;
					}
					$checkReqIndexesByPrefix[$prefix][$index] = 1;
				}
			}

			// MultiHttpClient::runMulti opts
			$opts = [];

			foreach ( $executeReqs as $index => &$req ) {
				// Expand protocol-relative URLs
				if ( preg_match( '#^//#', $req['url'] ) ) {
					$req['url'] = wfExpandUrl( $req['url'], PROTO_CURRENT );
				}

				// Try to find a suitable timeout
				//
				// MultiHttpClient::runMulti opts is not per request,
				// so pick the shortest one
				if (
					isset( $req['reqTimeout'] ) &&
					( !isset( $opts['reqTimeout'] ) ||
						$req['reqTimeout'] < $opts['reqTimeout'] )
				) {
					$opts['reqTimeout'] = $req['reqTimeout'];
				}
			}

			// Run the actual work HTTP requests
			foreach ( $this->http->runMulti( $executeReqs, $opts ) as $index => $ranReq ) {
				$doneReqs[$index] = $ranReq;
				unset( $origPending[$index] );
			}
			$executeReqs = [];
			// Services can also replace requests with new ones, either to
			// defer the original or to set a proxy response to the original.
			// Any replacement requests executed above will need to be replaced
			// with new requests (eventually the original). The responses can be
			// forced by setting 'response' rather than actually be sent over the wire.
			$newReplaceReqsByService = [];
			foreach ( $checkReqIndexesByPrefix as $prefix => $servReqIndexes ) {
				$service = $this->getInstance( $prefix );
				// $doneReqs actually has the requests (with 'response' set)
				$servReqs = array_intersect_key( $doneReqs, $servReqIndexes );
				foreach ( $service->onResponses( $servReqs, $idFunc ) as $index => $req ) {
					// Services use unique IDs for replacement requests
					if ( isset( $servReqs[$index] ) || isset( $origPending[$index] ) ) {
						// A current or original request which was not modified
					} else {
						// Replacement requests with pre-set responses should not execute
						$newReplaceReqsByService[$prefix][$index] = $req;
					}
					if ( isset( $req['response'] ) ) {
						// Replacement requests with pre-set responses should not execute
						unset( $origPending[$index] );
						$doneReqs[$index] = $req;
					} else {
						// Update the request in case it was mangled
						$executeReqs[$index] = $req;
					}
				}
			}
			// Update index of requests to inspect for replacement
			$replaceReqsByService = $newReplaceReqsByService;
		} while ( count( $origPending ) );

		$responses = [];
		// Update $reqs to include 'response' and normalized request 'headers'.
		// This maintains the original order of $reqs.
		foreach ( $reqs as $origIndex => $req ) {
			$index = $armoredIndexMap[$origIndex];
			if ( !isset( $doneReqs[$index] ) ) {
				throw new UnexpectedValueException( "Response for request '$index' is NULL." );
			}
			$responses[$origIndex] = $doneReqs[$index]['response'];
		}

		return $responses;
	}

	/**
	 * @param string $prefix
	 * @return VirtualRESTService
	 */
	private function getInstance( $prefix ) {
		if ( !isset( $this->instances[$prefix] ) ) {
			throw new RuntimeException( "No service registered at prefix '{$prefix}'." );
		}

		if ( !( $this->instances[$prefix] instanceof VirtualRESTService ) ) {
			$config = $this->instances[$prefix]['config'];
			$class = $this->instances[$prefix]['class'];
			$service = new $class( $config );
			if ( !( $service instanceof VirtualRESTService ) ) {
				throw new UnexpectedValueException( "Registered service has the wrong class." );
			}
			$this->instances[$prefix] = $service;
		}

		return $this->instances[$prefix];
	}
}
