<?php

namespace MediaWiki\Deferred\LinksUpdate;

use Collation;
use MediaWiki\Category\Category;
use MediaWiki\Config\Config;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\JobQueue\JobQueueGroup;
use MediaWiki\JobQueue\Jobs\CategoryCountUpdateJob;
use MediaWiki\JobQueue\Utils\PurgeJobUtils;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\Page\PageReferenceValue;
use MediaWiki\Page\WikiPageFactory;
use MediaWiki\Parser\ParserOutput;
use MediaWiki\Parser\ParserOutputLinkTypes;
use MediaWiki\Parser\Sanitizer;
use MediaWiki\Storage\NameTableStore;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\Title\Title;
use Wikimedia\ObjectCache\WANObjectCache;
use Wikimedia\Rdbms\ILoadBalancer;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * categorylinks
 *
 * Link ID format: string[]
 *   - 0: Category name
 *   - 1: User-specified sort key (cl_sortkey_prefix)
 *
 * @since 1.38
 */
class CategoryLinksTable extends TitleLinksTable {
	public const VIRTUAL_DOMAIN = 'virtual-categorylinks';

	/**
	 * @var array Associative array of new links, with the category name in the
	 *   key. The value is a list consisting of the sort key prefix and the sort
	 *   key.
	 */
	private $newLinks = [];

	/**
	 * @var array|null Associative array of existing links, or null if it has
	 *   not been loaded yet
	 */
	private $existingLinks;

	/**
	 * @var array Associative array of saved timestamps, if there is a force
	 *   refresh due to a page move
	 */
	private $savedTimestamps = null;

	/** @var ILanguageConverter */
	private $languageConverter;

	/** @var \Collation */
	private $collation;

	/** @var string The collation name for cl_collation */
	private $collationName;

	/** @var string The table name */
	private $tableName = 'categorylinks';

	/** @var bool */
	private $isTempTable;

	/** @var string The category type, which depends on the source page */
	private $categoryType;

	/** @var NamespaceInfo */
	private $namespaceInfo;

	/** @var WikiPageFactory */
	private $wikiPageFactory;

	private NameTableStore $collationNameStore;
	private JobQueueGroup $jobQueueGroup;
	private HookRunner $hookRunner;

	/**
	 * @param LanguageConverterFactory $converterFactory
	 * @param NamespaceInfo $namespaceInfo
	 * @param WikiPageFactory $wikiPageFactory
	 * @param ILoadBalancer $loadBalancer
	 * @param WANObjectCache $WANObjectCache
	 * @param Config $config
	 * @param JobqueueGroup $jobQueueGroup
	 * @param HookContainer $hookContainer
	 * @param Collation $collation
	 * @param string $collationName
	 * @param string $tableName
	 * @param bool $isTempTable
	 */
	public function __construct(
		LanguageConverterFactory $converterFactory,
		NamespaceInfo $namespaceInfo,
		WikiPageFactory $wikiPageFactory,
		ILoadBalancer $loadBalancer,
		WANObjectCache $WANObjectCache,
		Config $config,
		JobqueueGroup $jobQueueGroup,
		HookContainer $hookContainer,
		Collation $collation,
		$collationName,
		$tableName,
		$isTempTable
	) {
		$this->languageConverter = $converterFactory->getLanguageConverter();
		$this->namespaceInfo = $namespaceInfo;
		$this->wikiPageFactory = $wikiPageFactory;
		$this->collation = $collation;
		$this->jobQueueGroup = $jobQueueGroup;
		$this->hookRunner = new HookRunner( $hookContainer );
		$this->collationName = $collationName;
		$this->tableName = $tableName;
		$this->isTempTable = $isTempTable;

		$this->collationNameStore = new NameTableStore(
			$loadBalancer,
			$WANObjectCache,
			LoggerFactory::getInstance( 'SecondaryDataUpdate' ),
			'collation',
			'collation_id',
			'collation_name'
		);
	}

	/**
	 * Cache the category type after the source page has been set
	 */
	public function startUpdate() {
		$this->categoryType = $this->namespaceInfo
			->getCategoryLinkType( $this->getSourcePage()->getNamespace() );
	}

	public function setParserOutput( ParserOutput $parserOutput ) {
		$this->newLinks = [];
		$sourceTitle = Title::castFromPageIdentity( $this->getSourcePage() );
		$sortKeyInputs = [];
		foreach (
			$parserOutput->getLinkList( ParserOutputLinkTypes::CATEGORY )
			as [ 'link' => $targetTitle, 'sort' => $sortKey ]
		) {
			'@phan-var string $sortKey'; // sort key will never be null

			if ( $sortKey == '' ) {
				$sortKey = $parserOutput->getPageProperty( "defaultsort" ) ?? '';
			}
			$sortKey = $this->languageConverter->convertCategoryKey( $sortKey );

			// Clean up the sort key, regardless of source
			$sortKey = Sanitizer::decodeCharReferences( $sortKey );
			$sortKey = str_replace( "\n", '', $sortKey );

			// If the sort key is longer then 255 bytes, it is truncated by DB,
			// and then doesn't match when comparing existing vs current
			// categories, causing T27254.
			$sortKeyPrefix = mb_strcut( $sortKey, 0, 255 );

			$name = $targetTitle->getDBkey();
			$targetTitle = Title::castFromLinkTarget( $targetTitle );
			$this->languageConverter->findVariantLink( $name, $targetTitle, true );
			// Ignore the returned text, DB key should be used for links (T328477).
			$name = $targetTitle->getDBKey();

			// Treat custom sort keys as a prefix, so that if multiple
			// things are forced to sort as '*' or something, they'll
			// sort properly in the category rather than in page_id
			// order or such.
			$sortKeyInputs[$name] = $sourceTitle->getCategorySortkey( $sortKeyPrefix );
			$this->newLinks[$name] = [ $sortKeyPrefix ];
		}
		$sortKeys = $this->collation->getSortKeys( $sortKeyInputs );
		foreach ( $sortKeys as $name => $sortKey ) {
			$this->newLinks[$name][1] = $sortKey;
		}
	}

	/** @inheritDoc */
	protected function getTableName() {
		return $this->tableName;
	}

	/** @inheritDoc */
	protected function getFromField() {
		return 'cl_from';
	}

	/** @inheritDoc */
	protected function getExistingFields() {
		$fields = [ 'lt_title', 'cl_sortkey_prefix' ];

		if ( $this->needForcedLinkRefresh() ) {
			$fields[] = 'cl_timestamp';
		}

		return $fields;
	}

	/**
	 * Get the new link IDs. The link ID is a list with the name in the first
	 * element and the sort key prefix in the second element.
	 *
	 * @return iterable<array>
	 */
	protected function getNewLinkIDs() {
		foreach ( $this->newLinks as $name => [ $prefix, ] ) {
			yield [ (string)$name, $prefix ];
		}
	}

	/**
	 * Get the existing links from the database
	 */
	private function fetchExistingLinks() {
		$this->existingLinks = [];
		$this->savedTimestamps = [];
		$force = $this->needForcedLinkRefresh();
		foreach ( $this->fetchExistingRows() as $row ) {
			$this->existingLinks[$row->lt_title] = $row->cl_sortkey_prefix;
			if ( $force ) {
				$this->savedTimestamps[$row->lt_title] = $row->cl_timestamp;
			}
		}
	}

	/**
	 * Get the existing links as an associative array, with the category name
	 * in the key and the sort key prefix in the value.
	 *
	 * @return array
	 */
	private function getExistingLinks() {
		if ( $this->existingLinks === null ) {
			$this->fetchExistingLinks();
		}
		return $this->existingLinks;
	}

	private function getSavedTimestamps(): array {
		if ( $this->savedTimestamps === null ) {
			$this->fetchExistingLinks();
		}
		return $this->savedTimestamps;
	}

	/**
	 * @return \Generator
	 */
	protected function getExistingLinkIDs() {
		foreach ( $this->getExistingLinks() as $name => $sortkey ) {
			yield [ (string)$name, $sortkey ];
		}
	}

	/** @inheritDoc */
	protected function isExisting( $linkId ) {
		$links = $this->getExistingLinks();
		[ $name, $prefix ] = $linkId;
		return \array_key_exists( $name, $links ) && $links[$name] === $prefix;
	}

	/** @inheritDoc */
	protected function isInNewSet( $linkId ) {
		[ $name, $prefix ] = $linkId;
		return \array_key_exists( $name, $this->newLinks )
			&& $this->newLinks[$name][0] === $prefix;
	}

	/** @inheritDoc */
	protected function insertLink( $linkId ) {
		[ $name, $prefix ] = $linkId;
		$sortKey = $this->newLinks[$name][1];
		$savedTimestamps = $this->getSavedTimestamps();

		// Preserve cl_timestamp in the case of a forced refresh
		$timestamp = $this->getDB()->timestamp( $savedTimestamps[$name] ?? 0 );

		$targetFields = [];
		$targetFields['cl_target_id'] = $this->linkTargetLookup->acquireLinkTargetId(
			$this->makeTitle( $linkId ),
			$this->getDB()
		);
		$targetFields['cl_collation_id'] = $this->collationNameStore->acquireId( $this->collationName );

		$this->insertRow( $targetFields + [
			'cl_sortkey' => $sortKey,
			'cl_timestamp' => $timestamp,
			'cl_sortkey_prefix' => $prefix,
			'cl_type' => $this->categoryType,
		] );
	}

	/** @inheritDoc */
	protected function deleteLink( $linkId ) {
		$this->deleteRow( [
			'cl_target_id' => $this->linkTargetLookup->acquireLinkTargetId(
				$this->makeTitle( $linkId ),
				$this->getDB()
			)
		] );
	}

	/** @inheritDoc */
	protected function needForcedLinkRefresh() {
		// cl_sortkey and possibly cl_type will change if it is a page move
		return $this->isMove();
	}

	/** @inheritDoc */
	protected function makePageReferenceValue( $linkId ): PageReferenceValue {
		return PageReferenceValue::localReference( NS_CATEGORY, $linkId[0] );
	}

	/** @inheritDoc */
	protected function makeTitle( $linkId ): Title {
		return Title::makeTitle( NS_CATEGORY, $linkId[0] );
	}

	/** @inheritDoc */
	protected function deduplicateLinkIds( $linkIds ) {
		$seen = [];
		foreach ( $linkIds as $linkId ) {
			if ( !\array_key_exists( $linkId[0], $seen ) ) {
				$seen[$linkId[0]] = true;
				yield $linkId;
			}
		}
	}

	protected function finishUpdate() {
		if ( $this->isTempTable ) {
			// Don't do invalidations for temporary collations
			return;
		}

		// A update of sortkey on move is detected as insert + delete,
		// but the categories does not need to update the counters or invalidate caches
		$allInsertedLinks = array_column( $this->insertedLinks, 0 );
		$allDeletedLinks = array_column( $this->deletedLinks, 0 );
		$insertedLinks = array_diff( $allInsertedLinks, $allDeletedLinks );
		$deletedLinks = array_diff( $allDeletedLinks, $allInsertedLinks );

		$this->invalidateCategories( $insertedLinks, $deletedLinks );
		if ( $insertedLinks || $deletedLinks ) {
			$this->jobQueueGroup->lazyPush(
				CategoryCountUpdateJob::newSpec(
					$this->getSourcePage(),
					$insertedLinks,
					$deletedLinks,
					$this->getBatchSize()
				)
			);
		}

		$wp = $this->wikiPageFactory->newFromTitle( $this->getSourcePage() );

		foreach ( $insertedLinks as $catName ) {
			$cat = Category::newFromName( $catName );
			$this->hookRunner->onCategoryAfterPageAdded( $cat, $wp );
		}

		foreach ( $deletedLinks as $catName ) {
			$cat = Category::newFromName( $catName );
			$this->hookRunner->onCategoryAfterPageRemoved( $cat, $wp, $this->getSourcePage()->getId() );
		}
	}

	private function invalidateCategories( array $insertedLinks, array $deletedLinks ) {
		$changedCategoryNames = array_merge(
			$insertedLinks,
			$deletedLinks
		);
		PurgeJobUtils::invalidatePages(
			$this->getDB(), NS_CATEGORY, $changedCategoryNames );
	}

	protected function linksTargetNormalizationStage(): int {
		return SCHEMA_COMPAT_NEW;
	}

	/** @inheritDoc */
	protected function virtualDomain(): string {
		return self::VIRTUAL_DOMAIN;
	}

	protected function fetchExistingRows(): IResultWrapper {
		return $this->getDB()->newSelectQueryBuilder()
			->select( $this->getExistingFields() )
			->from( $this->getTableName() )
			->join( 'linktarget', null, [ 'cl_target_id=lt_id' ] )
			->where( $this->getFromConds() )
			->caller( __METHOD__ )
			->fetchResultSet();
	}
}
