<?php

use MediaWiki\Extension\CentralAuth\CentralAuthSessionManager;
use MediaWiki\Extension\CentralAuth\CentralAuthTokenManager;
use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
use MediaWiki\Request\WebRequest;
use MediaWiki\Session\SessionBackend;
use MediaWiki\Session\SessionInfo;
use MediaWiki\Session\SessionProvider;
use MediaWiki\Session\UserInfo;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentityLookup;
use MediaWiki\User\UserNameUtils;
use Wikimedia\LightweightObjectStore\ExpirationAwareness;

/**
 * Base class for token based CentralAuth SessionProviders for use with different kinds of APIs.
 *
 * This base class provides functionality for looking at a token from the request,
 * and checking that it corresponds to an existing token generated by
 * CentralAuthTokenProvider.
 * If the token is present but invalid, a CentralAuthTokenSessionProvider returns a
 * bogus SessionInfo to prevent other SessionProviders from establishing a session.
 */
abstract class CentralAuthTokenSessionProvider extends SessionProvider {

	private UserIdentityLookup $userIdentityLookup;
	private CentralAuthSessionManager $sessionManager;
	protected CentralAuthTokenManager $tokenManager;

	/**
	 * @param UserIdentityLookup $userIdentityLookup
	 * @param CentralAuthSessionManager $sessionManager
	 * @param CentralAuthTokenManager $tokenManager
	 */
	public function __construct(
		UserIdentityLookup $userIdentityLookup,
		CentralAuthSessionManager $sessionManager,
		CentralAuthTokenManager $tokenManager
	) {
		parent::__construct();
		$this->userIdentityLookup = $userIdentityLookup;
		$this->sessionManager = $sessionManager;
		$this->tokenManager = $tokenManager;
	}

	/**
	 * Returns a bogus session, which can be used to prevent other SessionProviders
	 * from attemption to establish a session.
	 *
	 * May be overridden by subclasses to somehow cause error handling ot be triggered later.
	 *
	 * Per default, it just returns null.
	 *
	 * @param string $code Error code
	 * @param string|array $error Error message key, or key+parameters
	 * @return SessionInfo|null
	 */
	protected function makeBogusSessionInfo( $code, $error ) {
		// Then return an appropriate SessionInfo
		$id = $this->hashToSessionId( 'bogus' );
		return new SessionInfo( SessionInfo::MAX_PRIORITY, [
			'provider' => $this,
			'id' => $id,
			'userInfo' => UserInfo::newAnonymous(),
			'persisted' => false,
			'metadata' => [
				'error-code' => $code,
				'error' => $error,
			],
		] );
	}

	/**
	 * @param WebRequest $request
	 * @return mixed `null` indicates that no token was provided, `false` indicates an invalid token,
	 *   any other value is detokenized data
	 */
	abstract protected function getTokenDataFromRequest( WebRequest $request );

	/** @inheritDoc */
	public function provideSessionInfo( WebRequest $request ) {
		$data = $this->getTokenDataFromRequest( $request );
		if ( $data === null ) {
			return null;
		}

		$this->logger->debug( __METHOD__ . ': Found a token!' );

		if ( !is_array( $data ) ||
			!isset( $data['userName'] ) ||
			!isset( $data['token'] ) ||
			!isset( $data['origin'] ) ||
			!isset( $data['originSessionId'] )
		) {
			$this->logger->info( __METHOD__ . ': centralauthtoken is invalid' );
			return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
		}

		$userName = $data['userName'];
		$authToken = $data['token'];

		// Clean up username
		$userName = $this->userNameUtils->getCanonical( $userName );
		if ( !$userName ) {
			$this->logger->info( __METHOD__ . ': invalid username' );
			return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
		}
		if ( !$this->userNameUtils->isUsable( $userName ) ) {
			$this->logger->info( __METHOD__ . ': unusable username' );
			return $this->makeBogusSessionInfo( 'badusername',
				[ 'apierror-centralauth-badusername', wfEscapeWikiText( $userName ) ] );
		}

		// Try the central user
		$centralUser = CentralAuthUser::getInstanceByName( $userName );

		// Skip if they're being renamed
		if ( $centralUser->renameInProgress() ) {
			$this->logger->info( __METHOD__ . ': rename in progress' );
			return $this->makeBogusSessionInfo(
				'renameinprogress', 'apierror-centralauth-renameinprogress'
			);
		}

		if ( !$centralUser->exists() ) {
			$this->logger->info( __METHOD__ . ': global account doesn\'t exist' );
			return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
		}
		if ( !$centralUser->isAttached() ) {
			$userIdentity = $this->userIdentityLookup->getUserIdentityByName( $userName );
			if ( $userIdentity && $userIdentity->isRegistered() ) {
				$this->logger->info( __METHOD__ . ': not attached and local account exists' );
				return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
			}
		}

		$key = $this->sessionManager->makeSessionKey( 'api-token-blacklist', (string)$centralUser->getId() );
		$sessionStore = $this->sessionManager->getSessionStore();
		if ( $sessionStore->get( $key ) ) {
			$this->logger->info( __METHOD__ . ': user is blacklisted' );
			return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
		}

		if ( $centralUser->authenticateWithToken( $authToken ) != 'ok' ) {
			$this->logger->info( __METHOD__ . ': token mismatch' );
			return $this->makeBogusSessionInfo( 'badtoken', 'apierror-centralauth-badtoken' );
		}

		$this->logger->debug( __METHOD__ . ': logged in from session' );

		$info = [
			'userInfo' => UserInfo::newFromName( $userName, true ),
			'provider' => $this,
			'id' => $this->hashToSessionId( implode( "\n", $data ) ),
			'persisted' => true,
			'forceUse' => true,
		];

		return new SessionInfo( SessionInfo::MAX_PRIORITY, $info );
	}

	public function persistsSessionId() {
		return false;
	}

	public function canChangeUser() {
		return false;
	}

	/** @inheritDoc */
	public function persistSession(
		SessionBackend $session, WebRequest $request
	) {
		// Nothing to do
	}

	/** @inheritDoc */
	public function unpersistSession( WebRequest $request ) {
		// Nothing to do
	}

	/** @inheritDoc */
	public function invalidateSessionsForUser( User $user ) {
		$centralUser = CentralAuthUser::getPrimaryInstance( $user );
		if ( $centralUser->exists() && ( $centralUser->isAttached() || !$user->isRegistered() ) ) {
			$centralUser->resetAuthToken();
		}
	}

	/** @inheritDoc */
	public function preventSessionsForUser( $username ) {
		$username = $this->userNameUtils->getCanonical( $username, UserNameUtils::RIGOR_VALID );
		if ( !$username ) {
			return;
		}

		$centralUser = CentralAuthUser::getInstanceByName( $username );
		if ( !$centralUser->exists() ) {
			return;
		}

		// Assume blacklisting for a day will be enough because we assume by
		// then CentralAuth itself will have been instructed to more
		// permanently block the user.
		$sessionStore = $this->sessionManager->getSessionStore();
		$key = $this->sessionManager->makeSessionKey( 'api-token-blacklist', (string)$centralUser->getId() );
		$sessionStore->set( $key, true, ExpirationAwareness::TTL_DAY );
	}

}
