<?php

namespace MediaWiki\Extension\CheckUser\Services;

use LogicException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Extension\CheckUser\ClientHints\ClientHintsData;
use MediaWiki\Extension\CheckUser\ClientHints\ClientHintsReferenceIds;
use MediaWiki\Logging\DatabaseLogEntry;
use MediaWiki\MainConfigNames;
use MediaWiki\Revision\RevisionLookup;
use Psr\Log\LoggerInterface;
use StatusValue;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * Service to insert and delete user-agent client hint values and their associations with rows in cu_changes,
 * cu_log_event and cu_private_event.
 */
class UserAgentClientHintsManager {

	public const CONSTRUCTOR_OPTIONS = [
		'CUDMaxAge',
		MainConfigNames::UpdateRowsPerQuery,
	];

	public const SUPPORTED_TYPES = [
		'revision',
		'privatelog',
	];

	/**
	 * TINYINT references for use in cu_useragent_clienthints_map.uachm_reference_type
	 */
	// Identifier for the cu_changes table
	public const IDENTIFIER_CU_CHANGES = 0;
	// Identifier for the cu_log_event table
	public const IDENTIFIER_CU_LOG_EVENT = 1;
	// Identifier for the cu_private_event table
	public const IDENTIFIER_CU_PRIVATE_EVENT = 2;

	public const IDENTIFIER_TO_TABLE_NAME_MAP = [
		self::IDENTIFIER_CU_CHANGES => 'cu_changes',
		self::IDENTIFIER_CU_LOG_EVENT => 'cu_log_event',
		self::IDENTIFIER_CU_PRIVATE_EVENT => 'cu_private_event',
	];
	public const IDENTIFIER_TO_COLUMN_NAME_MAP = [
		self::IDENTIFIER_CU_CHANGES => 'cuc_this_oldid',
		self::IDENTIFIER_CU_LOG_EVENT => 'cule_log_id',
		self::IDENTIFIER_CU_PRIVATE_EVENT => 'cupe_id',
	];

	/** @var string[] Client Hints data which can only be set via the HTTP headers */
	public const HEADER_ONLY_CLIENT_HINTS_DATA = [
		'isBrowser',
		'ja3n',
		'ja4h',
	];

	public function __construct(
		private readonly IConnectionProvider $dbProvider,
		private readonly RevisionLookup $revisionLookup,
		private readonly ServiceOptions $options,
		private readonly LoggerInterface $logger,
	) {
		$options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
	}

	/**
	 * Given an array of client hint data, a reference ID, and an identifier type, record the data to the
	 * cu_useragent_clienthints and cu_useragent_clienthints_map tables.
	 *
	 * @param ClientHintsData $clientHintsData
	 * @param int $referenceId An ID to use in `uachm_reference_id` column in the
	 *   cu_useragent_clienthints_map table
	 * @param string $type The type of event this data is associated with. Valid values are the values in
	 *   {@link UserAgentClientHintsManager::SUPPORTED_TYPES}.
	 * @param bool $usePrimary If true, use the primary DB for SELECT queries.
	 * @return StatusValue
	 */
	public function insertClientHintValues(
		ClientHintsData $clientHintsData,
		int $referenceId,
		string $type,
		bool $usePrimary = false
	): StatusValue {
		// Check if there are rows to insert to the map table.
		$rows = $clientHintsData->toDatabaseRows();
		if ( !count( $rows ) ) {
			// Nothing to insert, so return early.
			// Having nothing to insert isn't considered "bad", so return a new good
			// For example, a browser could choose to provide no Client Hints data but
			// still send an empty API request.
			return StatusValue::newGood();
		}

		// Check for existing entry of specifically HTTP Client Hints, returning early if so.
		// Other browser headers (such as x-is-browser) are ignored by this check, as they cannot
		// be stored by an API request and we need to allow API requests to still set other data
		$dbr = $this->dbProvider->getReplicaDatabase();
		$existingRecord = $dbr->newSelectQueryBuilder()
			->table( 'cu_useragent_clienthints_map' )
			->join( 'cu_useragent_clienthints', null, 'uachm_uach_id = uach_id' )
			->where( [
				'uachm_reference_type' => $this->getMapIdByType( $type ),
				'uachm_reference_id' => $referenceId,
				$dbr->expr( 'uach_name', '!=', self::HEADER_ONLY_CLIENT_HINTS_DATA ),
			] )
			->caller( __METHOD__ )
			->fetchRowCount();
		if ( $existingRecord ) {
			return StatusValue::newFatal(
				'checkuser-api-useragent-clienthints-mappings-exist',
				$type,
				$referenceId
			);
		}

		$clientHintsMappings = $this->selectClientHintMappings( $rows, $usePrimary, true );
		if ( $clientHintsMappings === false ) {
			$usePrimary = true;
			$clientHintsMappings = $this->selectClientHintMappings( $rows, $usePrimary, false );
		}
		return $this->insertMappingRows( $clientHintsMappings, $referenceId, $type );
	}

	/**
	 * Given an identifier for the type of event (e.g. 'revision'), return the relevant TINYINT
	 * for the table that the database entry for cu_useragent_clienthints_map refers to
	 *
	 * @param string $type
	 * @return int One of self::IDENTIFIER_* constants
	 */
	private function getMapIdByType( string $type ): int {
		return match ( $type ) {
			'revision' => self::IDENTIFIER_CU_CHANGES,
			'log' => self::IDENTIFIER_CU_LOG_EVENT,
			'privatelog' => self::IDENTIFIER_CU_PRIVATE_EVENT,
			default => throw new LogicException( "Invalid type $type" ),
		};
	}

	/**
	 * Insert rows into the cu_useragent_clienthints_map table.
	 *
	 * This links a foreign ID (e.g. "revision 1234") with client hint data values stored in cu_useragent_clienthints.
	 *
	 * @param int[] $clientHintMapping
	 * @param int $foreignId
	 * @param string $type
	 * @return StatusValue
	 * @see insertClientHintValues, which invokes this method.
	 */
	private function insertMappingRows(
		array $clientHintMapping,
		int $foreignId,
		string $type
	): StatusValue {
		// TINYINT reference to cu_changes, cu_log_event or cu_private_event.
		$idType = $this->getMapIdByType( $type );
		$mapRows = [];
		foreach ( $clientHintMapping as $clientHintId ) {
			$mapRows[] = [
				'uachm_uach_id' => $clientHintId,
				'uachm_reference_type' => $idType,
				'uachm_reference_id' => $foreignId,
			];
		}

		if ( count( $mapRows ) ) {
			$this->dbProvider->getPrimaryDatabase()->newInsertQueryBuilder()
				->insertInto( 'cu_useragent_clienthints_map' )
				->ignore()
				->rows( $mapRows )
				->caller( __METHOD__ )
				->execute();
		}
		return StatusValue::newGood();
	}

	/**
	 * Given reference IDs this method finds and deletes
	 * the mapping entries for these reference IDs.
	 *
	 * @param ClientHintsReferenceIds $clientHintsReferenceIds
	 * @return int The number of mapping rows deleted.
	 */
	public function deleteMappingRows( ClientHintsReferenceIds $clientHintsReferenceIds ): int {
		$dbw = $this->dbProvider->getPrimaryDatabase();

		// Keep a track of the number of mapping rows that are deleted.
		$mappingRowsDeleted = 0;
		foreach ( $clientHintsReferenceIds->getReferenceIds() as $mapId => $referenceIds ) {
			if ( !count( $referenceIds ) ) {
				continue;
			}
			// Delete the rows in cu_useragent_clienthints_map associated with these reference IDs
			do {
				// Fetch a batch of rows to delete from the DB (the primary key is all rows in the table,
				// so we need to fetch all of them).
				$batchToDelete = $dbw->newSelectQueryBuilder()
					->select( [ 'uachm_uach_id', 'uachm_reference_type', 'uachm_reference_id' ] )
					->from( 'cu_useragent_clienthints_map' )
					->where( [
						'uachm_reference_id' => $referenceIds,
						'uachm_reference_type' => $mapId,
					] )
					->limit( $this->options->get( MainConfigNames::UpdateRowsPerQuery ) )
					->caller( __METHOD__ )
					->fetchResultSet();
				if ( !$batchToDelete->count() ) {
					break;
				}
				// Construct a list of WHERE conditions which would delete all the rows for this batch.
				$batchDeleteConds = [];
				foreach ( $batchToDelete as $row ) {
					$batchDeleteConds[] = $dbw->andExpr( [
						'uachm_uach_id' => $row->uachm_uach_id,
						'uachm_reference_type' => $row->uachm_reference_type,
						'uachm_reference_id' => $row->uachm_reference_id,
					] );
				}
				// Perform the deletion for this batch
				$dbw->newDeleteQueryBuilder()
					->deleteFrom( 'cu_useragent_clienthints_map' )
					->where( $dbw->orExpr( $batchDeleteConds ) )
					->caller( __METHOD__ )
					->execute();
				$mappingRowsDeleted += $dbw->affectedRows();
			} while ( $batchToDelete->count() );
		}
		return $mappingRowsDeleted;
	}

	/**
	 * Checks 100 rows with the smallest uachm_reference_id
	 * for each uachm_reference_type value to see whether their
	 * associated entry referenced by the uachm_reference_id
	 * value has been already purged.
	 *
	 * If it reaches an entry that is not orphaned, the checks are
	 * stopped as items with a larger reference ID are unlikely to
	 * be orphaned.
	 *
	 * This catches rows that have been left without deletion
	 * due to unforeseen circumstances, as described in T350681.
	 *
	 * @return int The number of orphaned map rows deleted.
	 */
	public function deleteOrphanedMapRows(): int {
		$dbw = $this->dbProvider->getPrimaryDatabase();
		$dbr = $this->dbProvider->getReplicaDatabase();

		// Keep a track of the number of mapping rows that are deleted.
		$mappingRowsDeleted = 0;
		foreach ( self::IDENTIFIER_TO_TABLE_NAME_MAP as $mappingId => $table ) {
			// Get 100 rows with the given mapping ID
			$resultSet = $dbr->newSelectQueryBuilder()
				->select( 'uachm_reference_id' )
				->table( 'cu_useragent_clienthints_map' )
				->where( [ 'uachm_reference_type' => $mappingId ] )
				->orderBy( 'uachm_reference_id' )
				->groupBy( 'uachm_reference_id' )
				->limit( 100 )
				->caller( __METHOD__ )
				->fetchResultSet();
			foreach ( $resultSet as $row ) {
				// For each row, check if the ::isMapRowOrphaned method
				// indicates that the row is orphaned.
				$referenceId = $row->uachm_reference_id;
				$mapRowIsOrphaned = $this->isMapRowOrphaned( $referenceId, $mappingId );
				if ( $mapRowIsOrphaned ) {
					// If the map row is orphaned, then perform the deletion
					// and add the affected rows count to the return count.
					$dbw->newDeleteQueryBuilder()
						->deleteFrom( 'cu_useragent_clienthints_map' )
						->where( [
							'uachm_reference_id' => $referenceId,
							'uachm_reference_type' => $mappingId,
						] )
						->caller( __METHOD__ )
						->execute();
					$mappingRowsDeleted += $dbw->affectedRows();
				} else {
					// If the map row is probably not orphaned, then just stop processing
					// the rows in this table.
					break;
				}
			}
		}
		return $mappingRowsDeleted;
	}

	/**
	 * Returns whether rows with the given $referenceId and $mappingId
	 * in cu_useragent_clienthints_map are likely orphaned.
	 *
	 * @param int $referenceId
	 * @param int $mappingId
	 * @return bool
	 */
	private function isMapRowOrphaned( int $referenceId, int $mappingId ): bool {
		if ( !array_key_exists( $mappingId, self::IDENTIFIER_TO_TABLE_NAME_MAP ) ) {
			throw new LogicException( "Unrecognised map ID '$mappingId'" );
		}

		$dbr = $this->dbProvider->getReplicaDatabase();

		if ( !in_array( $mappingId, [ self::IDENTIFIER_CU_LOG_EVENT, self::IDENTIFIER_CU_CHANGES ] ) ) {
			// If the mapping ID is not for cu_changes or cu_log_event,
			// query the table directly to check if the associated reference ID
			// exists in the table.
			return !$dbr->newSelectQueryBuilder()
				->field( '1' )
				->table( self::IDENTIFIER_TO_TABLE_NAME_MAP[$mappingId] )
				->where( [ self::IDENTIFIER_TO_COLUMN_NAME_MAP[$mappingId] => $referenceId ] )
				->caller( __METHOD__ )
				->fetchField();
		}
		// If the mapping ID is for cu_changes or cu_log_event,
		// then query the revision table or logging table respectively
		// for the associated timestamp to determine if the map
		// row should have already been deleted.
		$associatedTimestamp = null;
		if ( $mappingId === self::IDENTIFIER_CU_CHANGES ) {
			// Get the timestamp from the revision lookup service
			$associatedTimestamp = $this->revisionLookup->getRevisionById( $referenceId )
				?->getTimestamp();
		} elseif ( $mappingId === self::IDENTIFIER_CU_LOG_EVENT ) {
			// Get the timestamp from using DatabaseLogEntry::newFromId
			$associatedTimestamp = DatabaseLogEntry::newFromId( $referenceId, $dbr )
				?->getTimestamp();
		}
		// The map rows are considered orphaned if of the following any apply:
		// * There is no timestamp for the revision or log event (should be generally impossible for this
		//   to be the case).
		// * No such reference ID exists (i.e. no such revision ID or log ID)
		// * The timestamp associated with the revision or log event is before the
		//   wgCUDMaxAge + 100 seconds ago to the current time.
		//
		// The 100 seconds are added to wgCUDMaxAge to prevent attempting to delete map rows
		// that would have been normally deleted. This code is intended to catch map rows that
		// were not deleted normally.
		return !$associatedTimestamp ||
			$associatedTimestamp < ConvertibleTimestamp::convert(
				TS_MW,
				ConvertibleTimestamp::time() - ( $this->options->get( 'CUDMaxAge' ) + 100 )
			);
	}

	/**
	 * Helper method to find the id for client hint values.
	 * Missing values are inserted to generate new ids on the database.
	 * In that case the function needs to be recalled to select the new ids.
	 *
	 * E.g. if "architecture: arm" does not exists as a name/value pair, insert it.
	 *
	 * @param array[] $rows An array of arrays, where each array contains a key/value pair:
	 *  uach_name => "some name",
	 *  uach_value => "some value"
	 * @param bool $usePrimary If true, use the primary DB for SELECT queries.
	 * @param bool $insertMissingData If true, insert missing data
	 * @return int[]|false
	 */
	private function selectClientHintMappings( array $rows, bool $usePrimary, bool $insertMissingData ) {
		$db = $usePrimary ? $this->dbProvider->getPrimaryDatabase() : $this->dbProvider->getReplicaDatabase();

		$orExpr = [];
		$rowsToInsert = [];
		foreach ( $rows as $row ) {
			$orExpr[] = $db->andExpr( $row );
			$rowsToInsert[$row['uach_name'] . ':' . $row['uach_value']] = $row;
		}

		$dbRes = $db->newSelectQueryBuilder()
			->select( [ 'uach_id', 'uach_name', 'uach_value' ] )
			->from( 'cu_useragent_clienthints' )
			->where( $db->orExpr( $orExpr ) )
			->caller( __METHOD__ )
			->fetchResultSet();

		$clientHintMapping = [];
		foreach ( $dbRes as $dbRow ) {
			$clientHintMapping[] = (int)$dbRow->uach_id;
			unset( $rowsToInsert[$dbRow->uach_name . ':' . $dbRow->uach_value] );
		}

		if ( count( $rowsToInsert ) ) {
			if ( $insertMissingData ) {
				$this->dbProvider->getPrimaryDatabase()->newInsertQueryBuilder()
					->insertInto( 'cu_useragent_clienthints' )
					->ignore()
					->rows( array_values( $rowsToInsert ) )
					->caller( __METHOD__ )
					->execute();
				// False indicates the data has to be re-read to find ids for new inserted data
				return false;
			}
			foreach ( $rowsToInsert as $row ) {
				$this->logger->warning(
					"Lookup failed for cu_useragent_clienthints row with name {name} and value {value}.",
					[ $row['uach_name'], $row['uach_value'] ]
				);
			}
		}

		return $clientHintMapping;
	}

}
