<?php
/**
 * 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
 */

namespace MediaWiki\User;

use DBAccessObjectUtils;
use HTMLCheckMatrix;
use HTMLFormField;
use HTMLMultiSelectField;
use IContextSource;
use InvalidArgumentException;
use LanguageCode;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MediaWikiServices;
use Psr\Log\LoggerInterface;
use User;
use Wikimedia\Rdbms\ILoadBalancer;

/**
 * A service class to control user options
 * @since 1.35
 */
class UserOptionsManager extends UserOptionsLookup {

	/**
	 * @internal For use by ServiceWiring
	 */
	public const CONSTRUCTOR_OPTIONS = [
		'HiddenPrefs',
		'LocalTZoffset',
	];

	/** @var ServiceOptions */
	private $serviceOptions;

	/** @var DefaultOptionsLookup */
	private $defaultOptionsLookup;

	/** @var LanguageConverterFactory */
	private $languageConverterFactory;

	/** @var ILoadBalancer */
	private $loadBalancer;

	/** @var LoggerInterface */
	private $logger;

	/** @var array options modified withing this request */
	private $modifiedOptions = [];

	/**
	 * @var array Cached original user options with all the adjustments
	 *            like time correction and hook changes applied.
	 *            Ready to be returned.
	 */
	private $originalOptionsCache = [];

	/**
	 * @var array Cached original user options as fetched from database,
	 *            no adjustments applied.
	 */
	private $optionsFromDb = [];

	/** @var HookRunner */
	private $hookRunner;

	/** @var array Query flags used to retrieve options from database */
	private $queryFlagsUsedForCaching = [];

	/**
	 * @param ServiceOptions $options
	 * @param DefaultOptionsLookup $defaultOptionsLookup
	 * @param LanguageConverterFactory $languageConverterFactory
	 * @param ILoadBalancer $loadBalancer
	 * @param LoggerInterface $logger
	 * @param HookContainer $hookContainer
	 */
	public function __construct(
		ServiceOptions $options,
		DefaultOptionsLookup $defaultOptionsLookup,
		LanguageConverterFactory $languageConverterFactory,
		ILoadBalancer $loadBalancer,
		LoggerInterface $logger,
		HookContainer $hookContainer
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
		$this->serviceOptions = $options;
		$this->defaultOptionsLookup = $defaultOptionsLookup;
		$this->languageConverterFactory = $languageConverterFactory;
		$this->loadBalancer = $loadBalancer;
		$this->logger = $logger;
		$this->hookRunner = new HookRunner( $hookContainer );
	}

	/**
	 * @inheritDoc
	 */
	public function getDefaultOptions(): array {
		return $this->defaultOptionsLookup->getDefaultOptions();
	}

	/**
	 * @inheritDoc
	 */
	public function getOption(
		UserIdentity $user,
		string $oname,
		$defaultOverride = null,
		bool $ignoreHidden = false,
		int $queryFlags = self::READ_NORMAL
	) {
		# We want 'disabled' preferences to always behave as the default value for
		# users, even if they have set the option explicitly in their settings (ie they
		# set it, and then it was disabled removing their ability to change it).  But
		# we don't want to erase the preferences in the database in case the preference
		# is re-enabled again.  So don't touch $mOptions, just override the returned value
		if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( 'HiddenPrefs' ) ) ) {
			return $this->defaultOptionsLookup->getDefaultOption( $oname );
		}

		$options = $this->loadUserOptions( $user, $queryFlags );
		if ( array_key_exists( $oname, $options ) ) {
			return $options[$oname];
		}
		return $defaultOverride;
	}

	/**
	 * @inheritDoc
	 */
	public function getOptions(
		UserIdentity $user,
		int $flags = 0,
		int $queryFlags = self::READ_NORMAL
	): array {
		$options = $this->loadUserOptions( $user, $queryFlags );

		# We want 'disabled' preferences to always behave as the default value for
		# users, even if they have set the option explicitly in their settings (ie they
		# set it, and then it was disabled removing their ability to change it).  But
		# we don't want to erase the preferences in the database in case the preference
		# is re-enabled again.  So don't touch $mOptions, just override the returned value
		foreach ( $this->serviceOptions->get( 'HiddenPrefs' ) as $pref ) {
			$default = $this->defaultOptionsLookup->getDefaultOption( $pref );
			if ( $default !== null ) {
				$options[$pref] = $default;
			}
		}

		if ( $flags & self::EXCLUDE_DEFAULTS ) {
			$options = array_diff_assoc( $options, $this->defaultOptionsLookup->getDefaultOptions() );
		}

		return $options;
	}

	/**
	 * Set the given option for a user.
	 *
	 * You need to call saveOptions() to actually write to the database.
	 *
	 * @param UserIdentity $user
	 * @param string $oname The option to set
	 * @param mixed $val New value to set
	 */
	public function setOption( UserIdentity $user, string $oname, $val ) {
		// Explicitly NULL values should refer to defaults
		if ( $val === null ) {
			$val = $this->defaultOptionsLookup->getDefaultOption( $oname );
		}
		$this->modifiedOptions[$this->getCacheKey( $user )][$oname] = $val;
	}

	/**
	 * Reset certain (or all) options to the site defaults
	 *
	 * The optional parameter determines which kinds of preferences will be reset.
	 * Supported values are everything that can be reported by getOptionKinds()
	 * and 'all', which forces a reset of *all* preferences and overrides everything else.
	 *
	 * @note You need to call saveOptions() to actually write to the database.
	 *
	 * @param UserIdentity $user
	 * @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
	 * @param array|string $resetKinds Which kinds of preferences to reset.
	 *  Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
	 */
	public function resetOptions(
		UserIdentity $user,
		IContextSource $context,
		$resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
	) {
		$oldOptions = $this->loadUserOptions( $user, self::READ_LATEST );
		$defaultOptions = $this->defaultOptionsLookup->getDefaultOptions();

		if ( !is_array( $resetKinds ) ) {
			$resetKinds = [ $resetKinds ];
		}

		if ( in_array( 'all', $resetKinds ) ) {
			$newOptions = $defaultOptions + array_fill_keys( array_keys( $oldOptions ), null );
		} else {
			$optionKinds = $this->getOptionKinds( $user, $context );
			$resetKinds = array_intersect( $resetKinds, $this->listOptionKinds() );
			$newOptions = [];

			// Use default values for the options that should be deleted, and
			// copy old values for the ones that shouldn't.
			foreach ( $oldOptions as $key => $value ) {
				if ( in_array( $optionKinds[$key], $resetKinds ) ) {
					if ( array_key_exists( $key, $defaultOptions ) ) {
						$newOptions[$key] = $defaultOptions[$key];
					}
				} else {
					$newOptions[$key] = $value;
				}
			}
		}

		// TODO: Deprecate passing full user to the hook
		$this->hookRunner->onUserResetAllOptions(
			User::newFromIdentity( $user ), $newOptions, $oldOptions, $resetKinds
		);

		$this->modifiedOptions[$this->getCacheKey( $user )] = $newOptions;
	}

	/**
	 * Return a list of the types of user options currently returned by
	 * UserOptionsManager::getOptionKinds().
	 *
	 * Currently, the option kinds are:
	 * - 'registered' - preferences which are registered in core MediaWiki or
	 *                  by extensions using the UserGetDefaultOptions hook.
	 * - 'registered-multiselect' - as above, using the 'multiselect' type.
	 * - 'registered-checkmatrix' - as above, using the 'checkmatrix' type.
	 * - 'userjs' - preferences with names starting with 'userjs-', intended to
	 *              be used by user scripts.
	 * - 'special' - "preferences" that are not accessible via
	 *              UserOptionsLookup::getOptions or UserOptionsManager::setOptions.
	 * - 'unused' - preferences about which MediaWiki doesn't know anything.
	 *              These are usually legacy options, removed in newer versions.
	 *
	 * The API (and possibly others) use this function to determine the possible
	 * option types for validation purposes, so make sure to update this when a
	 * new option kind is added.
	 *
	 * @see getOptionKinds
	 * @return string[] Option kinds
	 */
	public function listOptionKinds(): array {
		return [
			'registered',
			'registered-multiselect',
			'registered-checkmatrix',
			'userjs',
			'special',
			'unused'
		];
	}

	/**
	 * Return an associative array mapping preferences keys to the kind of a preference they're
	 * used for. Different kinds are handled differently when setting or reading preferences.
	 *
	 * See UserOptionsManager::listOptionKinds for the list of valid option types that can be provided.
	 *
	 * @see UserOptionsManager::listOptionKinds
	 * @param UserIdentity $userIdentity
	 * @param IContextSource $context
	 * @param array|null $options Assoc. array with options keys to check as keys.
	 *   Defaults user options.
	 * @return string[] The key => kind mapping data
	 */
	public function getOptionKinds(
		UserIdentity $userIdentity,
		IContextSource $context,
		$options = null
	): array {
		if ( $options === null ) {
			$options = $this->loadUserOptions( $userIdentity );
		}

		// TODO: injecting the preferences factory creates a cyclic dependency between
		// PreferencesFactory and UserOptionsManager. See T250822
		$preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
		$user = User::newFromIdentity( $userIdentity );
		$prefs = $preferencesFactory->getFormDescriptor( $user, $context );
		$mapping = [];

		// Pull out the "special" options, so they don't get converted as
		// multiselect or checkmatrix.
		$specialOptions = array_fill_keys( $preferencesFactory->getSaveBlacklist(), true );
		foreach ( $specialOptions as $name => $value ) {
			unset( $prefs[$name] );
		}

		// Multiselect and checkmatrix options are stored in the database with
		// one key per option, each having a boolean value. Extract those keys.
		$multiselectOptions = [];
		foreach ( $prefs as $name => $info ) {
			if ( ( isset( $info['type'] ) && $info['type'] == 'multiselect' ) ||
				( isset( $info['class'] ) && $info['class'] == HTMLMultiSelectField::class )
			) {
				$opts = HTMLFormField::flattenOptions( $info['options'] ?? $info['options-messages'] );
				$prefix = $info['prefix'] ?? $name;

				foreach ( $opts as $value ) {
					$multiselectOptions["$prefix$value"] = true;
				}

				unset( $prefs[$name] );
			}
		}
		$checkmatrixOptions = [];
		foreach ( $prefs as $name => $info ) {
			if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
				( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class )
			) {
				$columns = HTMLFormField::flattenOptions( $info['columns'] );
				$rows = HTMLFormField::flattenOptions( $info['rows'] );
				$prefix = $info['prefix'] ?? $name;

				foreach ( $columns as $column ) {
					foreach ( $rows as $row ) {
						$checkmatrixOptions["$prefix$column-$row"] = true;
					}
				}

				unset( $prefs[$name] );
			}
		}

		// $value is ignored
		foreach ( $options as $key => $value ) {
			if ( isset( $prefs[$key] ) ) {
				$mapping[$key] = 'registered';
			} elseif ( isset( $multiselectOptions[$key] ) ) {
				$mapping[$key] = 'registered-multiselect';
			} elseif ( isset( $checkmatrixOptions[$key] ) ) {
				$mapping[$key] = 'registered-checkmatrix';
			} elseif ( isset( $specialOptions[$key] ) ) {
				$mapping[$key] = 'special';
			} elseif ( substr( $key, 0, 7 ) === 'userjs-' ) {
				$mapping[$key] = 'userjs';
			} else {
				$mapping[$key] = 'unused';
			}
		}

		return $mapping;
	}

	/**
	 * Saves the non-default options for this user, as previously set e.g. via
	 * setOption(), in the database's "user_properties" (preferences) table.
	 * Usually used via saveSettings().
	 * @param UserIdentity $user
	 * @internal
	 */
	public function saveOptions( UserIdentity $user ) {
		if ( !$user->isRegistered() ) {
			throw new InvalidArgumentException( __METHOD__ . ' was called on anon user' );
		}

		$userKey = $this->getCacheKey( $user );
		$modifiedOptions = $this->modifiedOptions[$userKey] ?? [];
		$originalOptions = $this->loadOriginalOptions( $user, self::READ_LATEST );
		if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) {
			return;
		}

		// TODO: only needed for old hook.
		$optionsToSave = array_merge( $originalOptions, $modifiedOptions );
		// Allow hooks to abort, for instance to save to a global profile.
		// Reset options to default state before saving.
		if ( !$this->hookRunner->onUserSaveOptions(
			User::newFromIdentity( $user ), $optionsToSave, $originalOptions )
		) {
			return;
		}

		// Delete any rows that were in the DB but are not in $optionsToSave
		$keysToDelete = array_keys(
			array_diff_key( $this->optionsFromDb[$userKey], $optionsToSave )
		);

		// Update rows that have changed
		$userId = $user->getId();
		$rowsToInsert = []; // all the new preference rows
		$rowsToPreserve = [];
		foreach ( $optionsToSave as $key => $value ) {
			// Don't store unchanged or default values
			$defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key );
			$oldValue = $this->optionsFromDb[$userKey][$key] ?? null;
			if ( !$this->isValueEqual( $value, $defaultValue ) ) {
				$row = [
					'up_user' => $userId,
					'up_property' => $key,
					'up_value' => $value,
				];
				if ( !$this->isValueEqual( $value, $oldValue ) ) {
					// Update by deleting and reinserting
					$rowsToInsert[] = $row;
					if ( $oldValue !== null ) {
						$keysToDelete[] = $key;
					}
				} else {
					// Preserve old value -- save the row for caching
					$rowsToPreserve[] = $row;
				}
			} elseif ( $oldValue !== null ) {
				// Delete row that is being set to its default
				$keysToDelete[] = $key;
			}
		}

		if ( !count( $keysToDelete ) && !count( $rowsToInsert ) ) {
			// Nothing to do
			return;
		}

		// Do the DELETE
		$dbw = $this->loadBalancer->getConnectionRef( DB_PRIMARY );
		if ( $keysToDelete ) {
			$dbw->delete(
				'user_properties',
				[
					'up_user' => $userId,
					'up_property' => $keysToDelete
				],
				__METHOD__
			);
		}
		if ( $rowsToInsert ) {
			// Insert the new preference rows
			$dbw->insert( 'user_properties', $rowsToInsert, __METHOD__, [ 'IGNORE' ] );
		}

		// We are storing these into the database, so that's what we will get if we fetch again
		// So, set the just saved options to the cache, but don't prevent some other part of the
		// code from locking the options again but saying READ_LATEST was used for caching.
		$this->setOptionsFromDb( $user, self::READ_LATEST,
			array_merge( $rowsToPreserve, $rowsToInsert ) );

		// It's pretty cheap to recalculate new original later
		// to apply whatever adjustments we apply when fetching from DB
		// and re-merge with the defaults.
		unset( $this->originalOptionsCache[$userKey] );
		// And nothing is modified anymore
		unset( $this->modifiedOptions[$userKey] );
	}

	/**
	 * Loads user options either from cache or from the database.
	 *
	 * @note Query flags are ignored for anons, since they do not have any
	 * options stored in the database. If the UserIdentity was itself
	 * obtained from a replica and doesn't have ID set due to replication lag,
	 * it will be treated as anon regardless of the query flags passed here.
	 *
	 * @param UserIdentity $user
	 * @param int $queryFlags
	 * @param array|null $data associative array of non-default options.
	 * @return array
	 * @internal To be called by User loading code to provide the $data
	 */
	public function loadUserOptions(
		UserIdentity $user,
		int $queryFlags = self::READ_NORMAL,
		array $data = null
	): array {
		$userKey = $this->getCacheKey( $user );
		$originalOptions = $this->loadOriginalOptions( $user, $queryFlags, $data );
		return array_merge( $originalOptions, $this->modifiedOptions[$userKey] ?? [] );
	}

	/**
	 * Clears cached user options.
	 * @internal To be used by User::clearInstanceCache
	 * @param UserIdentity $user
	 */
	public function clearUserOptionsCache( UserIdentity $user ) {
		$cacheKey = $this->getCacheKey( $user );
		unset( $this->modifiedOptions[$cacheKey] );
		unset( $this->optionsFromDb[$cacheKey] );
		unset( $this->originalOptionsCache[$cacheKey] );
		unset( $this->queryFlagsUsedForCaching[$cacheKey] );
	}

	/**
	 * Fetches the options directly from the database with no caches.
	 *
	 * @param UserIdentity $user
	 * @param int $queryFlags
	 * @param array|null $prefetchedOptions
	 * @return array
	 */
	private function loadOptionsFromDb(
		UserIdentity $user,
		int $queryFlags,
		array $prefetchedOptions = null
	): array {
		if ( $prefetchedOptions === null ) {
			$this->logger->debug( 'Loading options from database', [ 'user_id' => $user->getId() ] );
			[ $dbr, $options ] = $this->getDBAndOptionsForQueryFlags( $queryFlags );
			$res = $dbr->select(
				'user_properties',
				[ 'up_property', 'up_value' ],
				[ 'up_user' => $user->getId() ],
				__METHOD__,
				$options
			);
		} else {
			$res = [];
			foreach ( $prefetchedOptions as $name => $value ) {
				$res[] = [
					'up_property' => $name,
					'up_value' => $value,
				];
			}
		}
		return $this->setOptionsFromDb( $user, $queryFlags, $res );
	}

	/**
	 * Builds associative options array from rows fetched from DB.
	 *
	 * @param UserIdentity $user
	 * @param int $queryFlags
	 * @param iterable<object|array> $rows
	 * @return array
	 */
	private function setOptionsFromDb(
		UserIdentity $user,
		int $queryFlags,
		iterable $rows
	): array {
		$userKey = $this->getCacheKey( $user );
		$options = [];
		foreach ( $rows as $row ) {
			$row = (object)$row;
			// Convert '0' to 0. PHP's boolean conversion considers them both
			// false, but e.g. JavaScript considers the former as true.
			// @todo: T54542 Somehow determine the desired type (string/int/bool)
			//  and convert all values here.
			if ( $row->up_value === '0' ) {
				$row->up_value = 0;
			}
			$options[$row->up_property] = $row->up_value;
		}
		$this->optionsFromDb[$userKey] = $options;
		$this->queryFlagsUsedForCaching[$userKey] = $queryFlags;
		return $options;
	}

	/**
	 * Loads the original user options from the database and applies various transforms,
	 * like timecorrection. Runs hooks.
	 *
	 * @param UserIdentity $user
	 * @param int $queryFlags
	 * @param array|null $data associative array of non-default options
	 * @return array
	 */
	private function loadOriginalOptions(
		UserIdentity $user,
		int $queryFlags = self::READ_NORMAL,
		array $data = null
	): array {
		$userKey = $this->getCacheKey( $user );
		$defaultOptions = $this->defaultOptionsLookup->getDefaultOptions();
		if ( !$user->isRegistered() ) {
			// For unlogged-in users, load language/variant options from request.
			// There's no need to do it for logged-in users: they can set preferences,
			// and handling of page content is done by $pageLang->getPreferredVariant() and such,
			// so don't override user's choice (especially when the user chooses site default).
			$variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant();
			$defaultOptions['variant'] = $variant;
			$defaultOptions['language'] = $variant;
			$this->originalOptionsCache[$userKey] = $defaultOptions;
			return $defaultOptions;
		}

		// In case options were already loaded from the database before and no options
		// changes were saved to the database, we can use the cached original options.
		if ( $this->canUseCachedValues( $user, $queryFlags )
			&& isset( $this->originalOptionsCache[$userKey] )
		) {
			return $this->originalOptionsCache[$userKey];
		}

		$options = $this->loadOptionsFromDb( $user, $queryFlags, $data ) + $defaultOptions;
		// Replace deprecated language codes
		$options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] );
		// Fix up timezone offset (Due to DST it can change from what was stored in the DB)
		// ZoneInfo|offset|TimeZoneName
		if ( isset( $options['timecorrection'] ) ) {
			$options['timecorrection'] = ( new UserTimeCorrection(
				$options['timecorrection'],
				null,
				$this->serviceOptions->get( 'LocalTZoffset' )
			) )->toString();
		}

		// Need to store what we have so far before the hook to prevent
		// infinite recursion if the hook attempts to reload options
		$this->originalOptionsCache[$userKey] = $options;
		$this->queryFlagsUsedForCaching[$userKey] = $queryFlags;
		// TODO: Remove deprecated hook.
		$this->hookRunner->onUserLoadOptions(
			User::newFromIdentity( $user ), $options
		);
		$this->hookRunner->onLoadUserOptions( $user, $options );
		$this->originalOptionsCache[$userKey] = $options;
		return $options;
	}

	/**
	 * Gets a key for various caches.
	 * @param UserIdentity $user
	 * @return string
	 */
	private function getCacheKey( UserIdentity $user ): string {
		return $user->isRegistered() ? "u:{$user->getId()}" : 'anon';
	}

	/**
	 * @param int $queryFlags a bit field composed of READ_XXX flags
	 * @return array [ IDatabase $db, array $options ]
	 */
	private function getDBAndOptionsForQueryFlags( $queryFlags ): array {
		list( $mode, $options ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
		return [ $this->loadBalancer->getConnectionRef( $mode, [] ), $options ];
	}

	/**
	 * Determines if it's ok to use cached options values for a given user and query flags
	 * @param UserIdentity $user
	 * @param int $queryFlags
	 * @return bool
	 */
	private function canUseCachedValues( UserIdentity $user, int $queryFlags ): bool {
		if ( !$user->isRegistered() ) {
			// Anon users don't have options stored in the database,
			// so $queryFlags are ignored.
			return true;
		}
		$userKey = $this->getCacheKey( $user );
		$queryFlagsUsed = $this->queryFlagsUsedForCaching[$userKey] ?? self::READ_NONE;
		return $queryFlagsUsed >= $queryFlags;
	}

	/**
	 * Determines whether two values are sufficiently similar that the database
	 * does not need to be updated to reflect the change. This is basically the
	 * same as comparing the result of Database::addQuotes().
	 *
	 * @param mixed $a
	 * @param mixed $b
	 * @return bool
	 */
	private function isValueEqual( $a, $b ) {
		if ( is_bool( $a ) ) {
			$a = (int)$a;
		}
		if ( is_bool( $b ) ) {
			$b = (int)$b;
		}
		return (string)$a === (string)$b;
	}
}
