<?php

namespace MediaWiki\Tests\Rest\Handler;

use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Permissions\Authority;
use MediaWiki\Rest\Handler;
use MediaWiki\Rest\HttpException;
use MediaWiki\Rest\Module\Module;
use MediaWiki\Rest\RequestInterface;
use MediaWiki\Rest\Response;
use MediaWiki\Rest\ResponseFactory;
use MediaWiki\Rest\ResponseInterface;
use MediaWiki\Rest\Router;
use MediaWiki\Rest\Validator\Validator;
use MediaWiki\Session\Session;
use MediaWiki\Tests\Rest\RestTestTrait;
use MediaWiki\Tests\Unit\DummyServicesTrait;
use MediaWiki\Tests\Unit\Permissions\MockAuthorityTrait;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\MockObject\MockObject;
use Wikimedia\ObjectFactory\ObjectFactory;

/**
 * A trait providing utility functions for testing Handler classes.
 * This trait is intended to be used on subclasses of MediaWikiUnitTestCase
 * or MediaWikiIntegrationTestCase.
 *
 * @stable to use
 */
trait HandlerTestTrait {
	use RestTestTrait;
	use DummyServicesTrait;
	use MockAuthorityTrait;
	use SessionHelperTestTrait;

	/**
	 * Calls init() on the Handler, supplying a mock RouteUrlProvider and ResponseFactory.
	 *
	 * @param Handler $handler
	 * @param RequestInterface $request
	 * @param array $config
	 * @param HookContainer|array $hooks Hook container or array of hooks
	 * @param Authority|null $authority
	 * @param Session|null $session Defaults to `$this->getSession( true )`
	 * @param Router|Module|null $routerOrModule
	 *
	 * @internal to the trait
	 */
	private function initHandler(
		Handler $handler,
		RequestInterface $request,
		$config = [],
		$hooks = [],
		?Authority $authority = null,
		?Session $session = null,
		$routerOrModule = null
	) {
		$formatter = $this->getDummyTextFormatter( true );
		$responseFactory = new ResponseFactory( [ 'qqx' => $formatter ] );

		$module = null;
		$router = null;

		if ( $routerOrModule instanceof Module ) {
			$module = $routerOrModule;
			$router = $module->getRouter();
		}

		if ( $routerOrModule instanceof Router ) {
			$router = $routerOrModule;
		}

		if ( !$module ) {
			if ( !$router ) {
				$router = $this->newRouter();
			}

			$module = $this->newModule( [ 'router' => $router ] );
		}

		if ( !$request->hasBody()
			&& in_array( $request->getMethod(), RequestInterface::BODY_METHODS )
		) {
			// Send an empty body if none was provided.
			$request->setParsedBody( [] );
		}

		$authority ??= $this->mockAnonUltimateAuthority();
		$hookContainer =
			$hooks instanceof HookContainer ? $hooks : $this->createHookContainer( $hooks );

		$session ??= $this->getSession( true );

		// TODO: Even if we are given a Router (either directly or via a Module), we aren't using
		//  its formatter, authority, or session to initialize the handler. We can individually
		//  override authority and session by passing them as parameters, but not formatter.
		//  Consider either adding a formatter parameter, or using these values from any supplied
		//  Router. (Router does not currently provide accessors, making this inconvenient.)
		$handler->initContext( $module, $config['path'] ?? 'test', $config );
		$handler->initServices( $authority, $responseFactory, $hookContainer );
		$handler->initSession( $session );
		$handler->initForExecute( $request );
	}

	/**
	 * @return MockObject&Router
	 */
	private function newRouter(): Router {
		$router = $this->createNoOpMock(
			Router::class,
			[
				'getRoutePath',
				'getRouteUrl',
				'isRestbaseCompatEnabled'
			]
		);
		$router->method( 'getRoutePath' )->willReturnCallback(
			static function ( $route, $path = [], $query = [] ) {
				foreach ( $path as $param => $value ) {
					$route = str_replace(
						'{' . $param . '}',
						urlencode( (string)$value ),
						$route
					);
				}

				return wfAppendQuery(
					'/rest' . $route,
					$query
				);
			}
		);
		$router->method( 'getRouteUrl' )->willReturnCallback(
			static function ( $route, $path = [], $query = [] ) use ( $router ) {
				return 'https://wiki.example.com' . $router->getRoutePath(
						$route,
						$path,
						$query
					);
			}
		);
		$router->method( 'isRestbaseCompatEnabled' )->willReturnCallback(
			static function ( RequestInterface $request ) {
				return $request->getHeaderLine( 'x-restbase-compat' ) === 'true';
			}
		);

		return $router;
	}

	/**
	 * Calls validate() on the Handler, with an appropriate Validator supplied.
	 *
	 * @internal to the trait
	 * @param Handler $handler
	 * @param null|Validator $validator
	 * @throws HttpException
	 */
	private function validateHandler(
		Handler $handler,
		?Validator $validator = null
	) {
		if ( !$validator ) {
			$serviceContainer = $this->getServiceContainer();
			$objectFactory = new ObjectFactory( $serviceContainer );
			$validator = new Validator( $objectFactory, $handler->getRequest(), $handler->getAuthority() );
		}
		$handler->validate( $validator );
	}

	/**
	 * Creates a mock Validator to bypass actual request query, path, and/or body param validation
	 *
	 * @internal to the trait
	 * @param array $queryPathParams
	 * @param array $bodyParams
	 * @return Validator|MockObject
	 */
	private function getMockValidator( array $queryPathParams, array $bodyParams ): Validator {
		$validator = $this->createNoOpMock(
			Validator::class,
			[
				'validateParams',
				'validateBodyParams',
				'validateBody',
				'detectExtraneousBodyFields',
			]
		);
		$validator->method( 'validateBody' )->willReturn( null );
		$validator->method( 'validateParams' )->willReturn( $queryPathParams );
		$validator->method( 'validateBodyParams' )->willReturn( $bodyParams );
		return $validator;
	}

	/**
	 * Executes the given Handler on the given request.
	 *
	 * @param Handler $handler
	 * @param RequestInterface $request
	 * @param array $config
	 * @param HookContainer|array $hooks Hook container or array of hooks
	 * @param array $validatedParams Path/query params to return as already valid
	 * @param array $validatedBody Body params to return as already valid
	 * @param Authority|null $authority
	 * @param Session|null $session Defaults to `$this->getSession( true )`
	 * @param Router|Module|null $routerOrModule
	 *
	 * @return ResponseInterface
	 */
	private function executeHandler(
		Handler $handler,
		RequestInterface $request,
		$config = [],
		$hooks = [],
		$validatedParams = [],
		$validatedBody = [],
		?Authority $authority = null,
		?Session $session = null,
		$routerOrModule = null
	): ResponseInterface {
		// supply defaults for required fields in $config
		$config += [ 'path' => '/test' ];

		$this->initHandler( $handler, $request, $config, $hooks, $authority, $session, $routerOrModule );
		$validator = null;
		if ( $validatedParams || $validatedBody ) {
			/** @var Validator|MockObject $validator */
			$validator = $this->getMockValidator( $validatedParams, $validatedBody );
		}
		$this->validateHandler( $handler, $validator );

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

		$ret = $handler->execute();

		$response = $ret instanceof Response ? $ret
			: $handler->getResponseFactory()->createFromReturnValue( $ret );

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

		return $response;
	}

	/**
	 * Executes the given Handler on the given request, parses the response body as JSON,
	 * and returns the result.
	 *
	 * @param Handler $handler
	 * @param RequestInterface $request
	 * @param array $config
	 * @param HookContainer|array $hooks Hook container or array of hooks
	 * @param array $validatedParams
	 * @param array $validatedBody
	 * @param Authority|null $authority
	 * @param Session|null $session Defaults to `$this->getSession( true )`
	 * @return array
	 */
	private function executeHandlerAndGetBodyData(
		Handler $handler,
		RequestInterface $request,
		$config = [],
		$hooks = [],
		$validatedParams = [],
		$validatedBody = [],
		?Authority $authority = null,
		?Session $session = null
	): array {
		$response = $this->executeHandler( $handler, $request, $config, $hooks,
			$validatedParams, $validatedBody, $authority, $session );

		$this->assertGreaterThanOrEqual( 200, $response->getStatusCode() );
		$this->assertLessThan( 300, $response->getStatusCode() );
		$this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );

		$data = json_decode( $response->getBody(), true );
		$this->assertIsArray( $data, 'Body must be a JSON array' );

		return $data;
	}

	/**
	 * Executes the given Handler on the given request, and returns the HttpException thrown.
	 * Fails if no HttpException is thrown.
	 *
	 * @param Handler $handler
	 * @param RequestInterface $request
	 * @param array $config
	 * @param HookContainer|array $hooks Hook container or array of hooks
	 *
	 * @return HttpException
	 */
	private function executeHandlerAndGetHttpException(
		Handler $handler,
		RequestInterface $request,
		$config = [],
		$hooks = []
	): HttpException {
		try {
			$this->executeHandler( $handler, $request, $config, $hooks );
			Assert::fail( 'Expected a HttpException to be thrown' );
		} catch ( HttpException $ex ) {
			return $ex;
		}
	}

}
