<?php

namespace MediaWiki\Rest\Module;

use LogicException;
use MediaWiki\Profiler\ProfilingContext;
use MediaWiki\Rest\BasicAccess\BasicAuthorizerInterface;
use MediaWiki\Rest\CorsUtils;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\PathTemplateMatcher\ModuleConfigurationException;
use MediaWiki\Rest\Reporter\ErrorReporter;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\ResponseException;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Rest\ResponseInterface;
use MediaWiki\Rest\Router;
use MediaWiki\Rest\Validator\Validator;
use Throwable;
use Wikimedia\Message\MessageValue;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\Stats\StatsFactory;

/**
 * A REST module represents a collection of endpoints.
 * The module object is responsible for generating a response for a given
 * request. This is typically done by routing requests to the appropriate
 * request handler.
 *
 * @since 1.43
 */
abstract class Module {

	/**
	 * @internal for use in cached module data
	 */
	public const CACHE_CONFIG_HASH_KEY = 'CONFIG-HASH';

	protected string $pathPrefix;
	protected ResponseFactory $responseFactory;
	private BasicAuthorizerInterface $basicAuth;
	private ObjectFactory $objectFactory;
	private Validator $restValidator;
	private ErrorReporter $errorReporter;
	private Router $router;

	private StatsFactory $stats;
	private ?CorsUtils $cors = null;

	/**
	 * @param Router $router
	 * @param string $pathPrefix
	 * @param ResponseFactory $responseFactory
	 * @param BasicAuthorizerInterface $basicAuth
	 * @param ObjectFactory $objectFactory
	 * @param Validator $restValidator
	 * @param ErrorReporter $errorReporter
	 */
	public function __construct(
		Router $router,
		string $pathPrefix,
		ResponseFactory $responseFactory,
		BasicAuthorizerInterface $basicAuth,
		ObjectFactory $objectFactory,
		Validator $restValidator,
		ErrorReporter $errorReporter
	) {
		$this->router = $router;
		$this->pathPrefix = $pathPrefix;
		$this->responseFactory = $responseFactory;
		$this->basicAuth = $basicAuth;
		$this->objectFactory = $objectFactory;
		$this->restValidator = $restValidator;
		$this->errorReporter = $errorReporter;

		$this->stats = StatsFactory::newNull();
	}

	public function getPathPrefix(): string {
		return $this->pathPrefix;
	}

	/**
	 * Return data that can later be used to initialize a new instance of
	 * this module in a fast and efficient way.
	 *
	 * @see initFromCacheData()
	 *
	 * @return array An associative array suitable to be processed by
	 *         initFromCacheData. Implementations are free to choose the format.
	 */
	abstract public function getCacheData(): array;

	/**
	 * Initialize from the given cache data if possible.
	 * This allows fast initialization based on data that was cached during
	 * a previous invocation of the module.
	 *
	 * Implementations are responsible for verifying that the cache data
	 * matches the information provided to the constructor, to protect against
	 * a situation where configuration was updated in a way that affects the
	 * operation of the module.
	 *
	 * @param array $cacheData Data generated by getCacheData(), implementations
	 *        are free to choose the format.
	 *
	 * @return bool true if the cache data could be used,
	 *         false if it was discarded.
	 * @see getCacheData()
	 */
	abstract public function initFromCacheData( array $cacheData ): bool;

	/**
	 * Create a Handler for the given path, taking into account the request
	 * method.
	 *
	 * If $prepExecution is true, the handler's prepareForExecute() method will
	 * be called, which will call postInitSetup(). The $request object will be
	 * updated with any path parameters and parsed body data.
	 *
	 * @unstable
	 *
	 * @param string $path
	 * @param RequestInterface $request The request to handle. If $forExecution
	 *        is true, this will be updated with the path parameters and parsed
	 *        body data as appropriate.
	 * @param bool $initForExecute Whether the handler and the request should be
	 *        prepared for execution. Callers that only need the Handler object
	 *        for access to meta-data should set this to false.
	 *
	 * @return Handler
	 * @throws HttpException If no handler was found
	 */
	public function getHandlerForPath(
		string $path,
		RequestInterface $request,
		bool $initForExecute = false
	): Handler {
		$requestMethod = strtoupper( $request->getMethod() );

		$match = $this->findHandlerMatch( $path, $requestMethod );

		if ( !$match['found'] && $requestMethod === 'HEAD' ) {
			// For a HEAD request, execute the GET handler instead if one exists.
			$match = $this->findHandlerMatch( $path, 'GET' );
		}

		if ( !$match['found'] ) {
			$this->throwNoMatch(
				$path,
				$request->getMethod(),
				$match['methods'] ?? []
			);
		}

		if ( isset( $match['handler'] ) ) {
			$handler = $match['handler'];
		} elseif ( isset( $match['spec'] ) ) {
			$handler = $this->instantiateHandlerObject( $match['spec'] );
		} else {
			throw new LogicException(
				'Match does not specify a handler instance or object spec.'
			);
		}

		// For backwards compatibility only. Handlers should get the path by
		// calling getPath(), not from the config array.
		$config = $match['config'] ?? [];
		$config['path'] ??= $match['path'];

		// Provide context about the module
		$handler->initContext( $this, $match['path'], $config );

		// Inject services and state from the router
		$this->getRouter()->prepareHandler( $handler );

		if ( $initForExecute ) {
			// Use rawurldecode so a "+" in path params is not interpreted as a space character.
			$pathParams = array_map( 'rawurldecode', $match['params'] ?? [] );
			$request->setPathParams( $pathParams );

			$handler->initForExecute( $request );
		}

		return $handler;
	}

	public function getRouter(): Router {
		return $this->router;
	}

	/**
	 * Determines which handler to use for the given path and returns an array
	 * describing the handler and initialization context.
	 *
	 * @param string $path
	 * @param string $requestMethod
	 *
	 * @return array<string,mixed>
	 *         - bool "found": Whether a match was found. If true, the `handler`
	 *           or `spec` field must be set.
	 *         - Handler handler: the Handler object to use. Either "handler" or
	 *           "spec" must be given.
	 *         - array "spec":" an object spec for use with ObjectFactory
	 *         - array "config": the route config, to be passed to Handler::initContext()
	 *         - string "path": the path the handler is responsible for,
	 *           including placeholders for path parameters.
	 *         - string[] "params": path parameters, to be passed the
	 *           Request::setPathPrams()
	 *         - string[] "methods": supported methods, if the path is known but
	 *           the method did not match. Only meaningful if "found" is false.
	 *           To be used in the Allow header of a 405 response and included
	 *           in CORS pre-flight.
	 */
	abstract protected function findHandlerMatch(
		string $path,
		string $requestMethod
	): array;

	/**
	 * Implementations of getHandlerForPath() should call this method when they
	 * cannot handle the requested path.
	 *
	 * @param string $path The requested path
	 * @param string $method The HTTP method of the current request
	 * @param string[] $allowed The allowed HTTP methods allowed by the path
	 *
	 * @return never
	 * @throws HttpException
	 */
	protected function throwNoMatch( string $path, string $method, array $allowed ): void {
		// Check for CORS Preflight. This response will *not* allow the request unless
		// an Access-Control-Allow-Origin header is added to this response.
		if ( $this->cors && $method === 'OPTIONS' && $allowed ) {
			// IDEA: Create a CorsHandler, which getHandlerForPath can return in this case.
			$response = $this->cors->createPreflightResponse( $allowed );
			throw new ResponseException( $response );
		}

		if ( $allowed ) {
			// There are allowed methods for this patch, so reply with Method Not Allowed.
			$response = $this->responseFactory->createLocalizedHttpError( 405,
				( new MessageValue( 'rest-wrong-method' ) )
					->textParams( $method )
					->commaListParams( $allowed )
					->numParams( count( $allowed ) )
			);
			$response->setHeader( 'Allow', $allowed );
			throw new ResponseException( $response );
		} else {
			// There are no allowed methods for this path, so the path was not found at all.
			$msg = ( new MessageValue( 'rest-no-match' ) )
				->plaintextParams( $path );
			throw new LocalizedHttpException( $msg, 404 );
		}
	}

	/**
	 * Find the handler for a request and execute it
	 */
	public function execute( string $path, RequestInterface $request ): ResponseInterface {
		$handler = null;
		$startTime = microtime( true );

		try {
			$handler = $this->getHandlerForPath( $path, $request, true );

			$response = $this->executeHandler( $handler );
		} catch ( HttpException $e ) {
			$extraData = [];
			if ( $this->router->isRestbaseCompatEnabled( $request )
				&& $e instanceof LocalizedHttpException
			) {
				$extraData = $this->router->getRestbaseCompatErrorData( $request, $e );
			}
			$response = $this->responseFactory->createFromException( $e, $extraData );
		} catch ( Throwable $e ) {
			// Note that $handler is allowed to be null here.
			$this->errorReporter->reportError( $e, $handler, $request );
			$response = $this->responseFactory->createFromException( $e );
		}

		$this->recordMetrics( $handler, $request, $response, $startTime );

		return $response;
	}

	private function recordMetrics(
		?Handler $handler,
		RequestInterface $request,
		ResponseInterface $response,
		float $startTime
	) {
		$latency = ( microtime( true ) - $startTime ) * 1000;

		// NOTE: The "/" prefix is for consistency with old logs. It's rather ugly.
		$pathForMetrics = $this->getPathPrefix();

		if ( $pathForMetrics !== '' ) {
			$pathForMetrics = '/' . $pathForMetrics;
		}

		$pathForMetrics .= $handler ? $handler->getPath() : '/UNKNOWN';

		// Replace any characters that may have a special meaning in the metrics DB.
		$pathForMetrics = strtr( $pathForMetrics, '{}:/.', '---__' );

		$statusCode = $response->getStatusCode();
		$requestMethod = $request->getMethod();
		if ( $statusCode >= 400 ) {
			// count how often we return which error code
			$this->stats->getCounter( 'rest_api_errors_total' )
				->setLabel( 'path', $pathForMetrics )
				->setLabel( 'method', $requestMethod )
				->setLabel( 'status', "$statusCode" )
				->copyToStatsdAt( [ "rest_api_errors.$pathForMetrics.$requestMethod.$statusCode" ] )
				->increment();
		} else {
			// measure how long it takes to generate a response
			$this->stats->getTiming( 'rest_api_latency_seconds' )
				->setLabel( 'path', $pathForMetrics )
				->setLabel( 'method', $requestMethod )
				->setLabel( 'status', "$statusCode" )
				->copyToStatsdAt( "rest_api_latency.$pathForMetrics.$requestMethod.$statusCode" )
				->observe( $latency );
		}
	}

	/**
	 * @internal for testing
	 *
	 * @return array[] An associative array, mapping path patterns to
	 *         a list of request methods supported for the path.
	 */
	abstract public function getDefinedPaths(): array;

	/**
	 * Get the allowed methods for a path.
	 * Useful to check for 405 wrong method and for generating OpenAPI specs.
	 *
	 * @param string $relPath A concrete request path.
	 * @return string[] A list of allowed HTTP request methods for the path.
	 *         If the path is not supported, the list will be empty.
	 */
	abstract public function getAllowedMethods( string $relPath ): array;

	/**
	 * Creates a handler from the given spec, but does not initialize it.
	 */
	protected function instantiateHandlerObject( array $spec ): Handler {
		/** @var $handler Handler (annotation for PHPStorm) */
		$handler = $this->objectFactory->createObject(
			$spec,
			[ 'assertClass' => Handler::class ]
		);

		return $handler;
	}

	/**
	 * Execute a fully-constructed handler
	 * @throws HttpException
	 */
	protected function executeHandler( Handler $handler ): ResponseInterface {
		ProfilingContext::singleton()->init( MW_ENTRY_POINT, $handler->getPath() );
		// Check for basic authorization, to avoid leaking data from private wikis
		$authResult = $this->basicAuth->authorize( $handler->getRequest(), $handler );
		if ( $authResult ) {
			return $this->responseFactory->createHttpError( 403, [ 'error' => $authResult ] );
		}

		// Check session (and session provider)
		$handler->checkSession();

		// Validate the parameters
		$handler->validate( $this->restValidator );

		// Check conditional request headers
		$earlyResponse = $handler->checkPreconditions();
		if ( $earlyResponse ) {
			return $earlyResponse;
		}

		// Run the main part of the handler
		$response = $handler->execute();
		if ( !( $response instanceof ResponseInterface ) ) {
			$response = $this->responseFactory->createFromReturnValue( $response );
		}

		// Set Last-Modified and ETag headers in the response if available
		$handler->applyConditionalResponseHeaders( $response );

		$handler->applyCacheControl( $response );

		return $response;
	}

	/**
	 * @param CorsUtils $cors
	 * @return self
	 */
	public function setCors( CorsUtils $cors ): self {
		$this->cors = $cors;

		return $this;
	}

	/**
	 * @internal for use by Router
	 *
	 * @param StatsFactory $stats
	 *
	 * @return self
	 */
	public function setStats( StatsFactory $stats ): self {
		$this->stats = $stats;

		return $this;
	}

	/**
	 * Loads a module specification from a file.
	 *
	 * This method does not know or care about the structure of the file
	 * other than that it must be JSON and contain a list or map
	 * (that is, a JSON array or object).
	 *
	 * @param string $fileName
	 *
	 * @internal
	 *
	 * @return array An associative or indexed array describing the module
	 * @throws ModuleConfigurationException
	 */
	public static function loadJsonFile( string $fileName ): array {
		$json = file_get_contents( $fileName );
		if ( $json === false ) {
			throw new ModuleConfigurationException(
				"Failed to load file `$fileName`"
			);
		}

		$spec = json_decode( $json, true );

		if ( !is_array( $spec ) ) {
			throw new ModuleConfigurationException(
				"Failed to parse `$fileName` as a JSON object"
			);
		}

		return $spec;
	}

	/**
	 * Return an array with data to be included in an OpenAPI "info" object
	 * describing this module.
	 *
	 * @see https://spec.openapis.org/oas/v3.0.0#info-object
	 * @return array
	 */
	public function getOpenApiInfo() {
		return [];
	}

	/**
	 * Returns fields to be included when describing this module in the
	 * discovery document.
	 *
	 * Supported keys are described in /docs/discovery-1.0.json#/definitions/Module
	 *
	 * @see /docs/discovery-1.0.json
	 * @see /docs/mwapi-1.0.json
	 * @see DiscoveryHandler
	 */
	public function getModuleDescription(): array {
		// TODO: Include the designated audience (T366567).
		// Note that each module object is designated for only one audience,
		// even if the spec allows multiple.
		$moduleId = $this->getPathPrefix();

		// Fields from OAS Info to include.
		// Note that mwapi-1.0 is based on OAS 3.0, so it doesn't support the
		// "summary" property introduced in 3.1.
		$infoFields = [ 'version', 'title', 'description' ];

		return [
			'moduleId' => $moduleId,
			'info' => array_intersect_key(
				$this->getOpenApiInfo(),
				array_flip( $infoFields )
			),
			'base' => $this->getRouter()->getRouteUrl(
				'/' . $moduleId
			),
			'spec' => $this->getRouter()->getRouteUrl(
				'/specs/v0/module/{module}', // hard-coding this here isn't very pretty
				[ 'module' => $moduleId == '' ? '-' : $moduleId ]
			)
		];
	}
}
