<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\PageTranslation;

use BagOStuff;
use JobQueueGroup;
use LogicException;
use MediaWiki\Cache\LinkBatchFactory;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MoveTranslatableBundleJob;
use MediaWiki\Extension\Translate\MessageGroupProcessing\SubpageListBuilder;
use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundle;
use MediaWiki\Extension\Translate\MessageGroupProcessing\TranslatableBundleFactory;
use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
use MediaWiki\Message\Message;
use MediaWiki\Page\MovePageFactory;
use MediaWiki\Status\Status;
use MediaWiki\Title\Title;
use MediaWiki\User\User;
use ObjectCacheFactory;
use SplObjectStorage;
use Wikimedia\Rdbms\IConnectionProvider;

/**
 * Contains the core logic to validate and move translatable bundles
 * @author Abijeet Patro
 * @license GPL-2.0-or-later
 * @since 2021.03
 */
class TranslatableBundleMover {
	private const LOCK_TIMEOUT = 3600 * 2;
	private const FETCH_TRANSLATABLE_SUBPAGES = true;
	private MovePageFactory $movePageFactory;
	private ?int $pageMoveLimit;
	private JobQueueGroup $jobQueue;
	private LinkBatchFactory $linkBatchFactory;
	private TranslatableBundleFactory $bundleFactory;
	private SubpageListBuilder $subpageBuilder;
	private IConnectionProvider $dbProvider;
	private BagOStuff $cache;
	private bool $pageMoveLimitEnabled = true;

	private const REDIRECTABLE_PAGE_TYPES = [
		'pt-movepage-list-source' => true,
		'pt-movepage-list-section' => false,
		'pt-movepage-list-nonmovable' => false,
		'pt-movepage-list-translatable' => false,
		'pt-movepage-list-translation' => false,
		'pt-movepage-list-other' => true
	];

	public function __construct(
		MovePageFactory $movePageFactory,
		JobQueueGroup $jobQueue,
		LinkBatchFactory $linkBatchFactory,
		TranslatableBundleFactory $bundleFactory,
		SubpageListBuilder $subpageBuilder,
		IConnectionProvider $dbProvider,
		ObjectCacheFactory $objectCacheFactory,
		?int $pageMoveLimit
	) {
		$this->movePageFactory = $movePageFactory;
		$this->jobQueue = $jobQueue;
		$this->pageMoveLimit = $pageMoveLimit;
		$this->linkBatchFactory = $linkBatchFactory;
		$this->bundleFactory = $bundleFactory;
		$this->subpageBuilder = $subpageBuilder;
		$this->dbProvider = $dbProvider;
		$this->cache = $objectCacheFactory->getInstance( CACHE_ANYTHING );
	}

	public function getPageMoveCollection(
		Title $source,
		?Title $target,
		User $user,
		string $reason,
		bool $moveSubPages,
		bool $moveTalkPages,
		bool $leaveRedirect
	): PageMoveCollection {
		$blockers = new SplObjectStorage();

		if ( !$target ) {
			$blockers[$source] = Status::newFatal( 'pt-movepage-block-base-invalid' );
			throw new ImpossiblePageMove( $blockers );
		}

		if ( $target->inNamespaces( NS_MEDIAWIKI, NS_TRANSLATIONS ) ) {
			$blockers[$source] = Status::newFatal( 'immobile-target-namespace', $target->getNsText() );
			throw new ImpossiblePageMove( $blockers );
		}

		$movePage = $this->movePageFactory->newMovePage( $source, $target );
		$status = $movePage->isValidMove();
		$status->merge( $movePage->probablyCanMove( $user, $reason ) );
		if ( !$status->isOK() ) {
			$blockers[$source] = $status;
		}

		// Don't spam the same errors for all pages if base page fails
		if ( count( $blockers ) ) {
			throw new ImpossiblePageMove( $blockers );
		}

		$pageCollection = $this->getPagesToMove(
			$source, $target, $moveSubPages, self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages, $leaveRedirect
		);

		// Collect all the old and new titles for checks
		$titles = [
			'tp' => $pageCollection->getTranslationPagesPair(),
			'subpage' => $pageCollection->getSubpagesPair(),
			'section' => $pageCollection->getUnitPagesPair()
		];

		// Check that all new titles are valid and count them. Add 1 for source page.
		$moveCount = 1;
		$lb = $this->linkBatchFactory->newLinkBatch();
		foreach ( $titles as $type => $list ) {
			$moveCount += count( $list );
			// Give grep a chance to find the usages:
			// pt-movepage-block-tp-invalid, pt-movepage-block-section-invalid,
			// pt-movepage-block-subpage-invalid
			foreach ( $list as $pair ) {
				$old = $pair->getOldTitle();
				$new = $pair->getNewTitle();

				if ( $new === null ) {
					$blockers[$old] = $this->getRenameMoveBlocker( $old, $type, $pair->getRenameErrorCode() );
					continue;
				}
				$lb->addObj( $old );
				$lb->addObj( $new );
			}
		}

		if ( $this->pageMoveLimitEnabled ) {
			if ( $this->pageMoveLimit !== null && $moveCount > $this->pageMoveLimit ) {
				$blockers[$source] = Status::newFatal(
					'pt-movepage-page-count-limit',
					Message::numParam( $this->pageMoveLimit )
				);
			}
		}

		// Stop further validation if there are blockers already.
		if ( count( $blockers ) ) {
			throw new ImpossiblePageMove( $blockers );
		}

		// Check that there are no move blockers
		$lb->setCaller( __METHOD__ )->execute();
		foreach ( $titles as $type => $list ) {
			foreach ( $list as $pair ) {
				$old = $pair->getOldTitle();
				$new = $pair->getNewTitle();

				/* This method has terrible performance:
				 * - 2 queries by core
				 * - 3 queries by lqt
				 * - and no obvious way to preload the data! */
				$movePage = $this->movePageFactory->newMovePage( $old, $new );
				$status = $movePage->isValidMove();
				// Do not check for permissions here, as these pages are not editable/movable
				// in regular use
				if ( !$status->isOK() ) {
					$blockers[$old] = $status;
				}

				/* Because of the poor performance, check only one of the possibly thousands
				 * of section pages and assume rest are fine. This assumes section pages are
				 * listed last in the array. */
				if ( $type === 'section' ) {
					break;
				}
			}
		}

		if ( count( $blockers ) ) {
			throw new ImpossiblePageMove( $blockers );
		}

		return $pageCollection;
	}

	public function moveAsynchronously(
		Title $source,
		Title $target,
		bool $moveSubPages,
		User $user,
		string $moveReason,
		bool $moveTalkPages,
		bool $leaveRedirect
	): void {
		$pageCollection = $this->getPagesToMove(
			$source, $target, $moveSubPages, !self::FETCH_TRANSLATABLE_SUBPAGES, $moveTalkPages, $leaveRedirect
		);
		$pagesToMove = $pageCollection->getListOfPages();
		$pagesToLeaveRedirect = $pageCollection->getListOfPagesToRedirect();

		$job = MoveTranslatableBundleJob::newJob(
			$source,
			$target,
			$pagesToMove,
			$pagesToLeaveRedirect,
			$moveReason,
			$user,
		);

		$this->lock( array_keys( $pagesToMove ) );
		$this->lock( array_values( $pagesToMove ) );

		$this->jobQueue->push( $job );
	}

	/**
	 * @param Title $source
	 * @param Title $target
	 * @param string[] $pagesToMove
	 * @param array<string,bool> $pagesToRedirect
	 * @param User $performer
	 * @param string $moveReason
	 * @param ?callable $progressCallback
	 */
	public function moveSynchronously(
		Title $source,
		Title $target,
		array $pagesToMove,
		array $pagesToRedirect,
		User $performer,
		string $moveReason,
		?callable $progressCallback = null
	): void {
		$sourceBundle = $this->bundleFactory->getValidBundle( $source );

		$this->move( $sourceBundle, $performer, $pagesToMove, $pagesToRedirect, $moveReason, $progressCallback );

		$this->bundleFactory->getStore( $sourceBundle )->move( $source, $target );

		$this->bundleFactory->getPageMoveLogger( $sourceBundle )
			->logSuccess( $performer, $target, $moveReason );
	}

	public function disablePageMoveLimit(): void {
		$this->pageMoveLimitEnabled = false;
	}

	public function enablePageMoveLimit(): void {
		$this->pageMoveLimitEnabled = true;
	}

	public static function shouldLeaveRedirect( string $pageType, bool $leaveRedirect ): bool {
		return self::REDIRECTABLE_PAGE_TYPES[ $pageType ] && $leaveRedirect;
	}

	private function getPagesToMove(
		Title $source,
		Title $target,
		bool $moveSubPages,
		bool $fetchTranslatableSubpages,
		bool $moveTalkPages,
		bool $leaveRedirect
	): PageMoveCollection {
		$sourceBundle = $this->bundleFactory->getValidBundle( $source );

		$classifiedSubpages = $this->subpageBuilder->getSubpagesPerType( $sourceBundle, $moveTalkPages );

		$talkPages = $moveTalkPages ? $classifiedSubpages['talkPages'] : [];
		$subpages = $moveSubPages ? $classifiedSubpages['normalSubpages'] : [];
		$nonMovableSubpages = [];

		$relatedTranslatablePageList = [];
		if ( $fetchTranslatableSubpages ) {
			$relatedTranslatablePageList = array_merge(
				$classifiedSubpages['translatableSubpages'],
				$classifiedSubpages['translatableTalkPages']
			);
		}

		$movePageFactory = $this->movePageFactory;
		$pageTitleRenamer = new PageTitleRenamer( $source, $target );
		$createOps = function ( array $pages, string $pageType )
			use ( $pageTitleRenamer, $talkPages, &$nonMovableSubpages, $leaveRedirect, $movePageFactory ) {
			$leaveRedirect = self::shouldLeaveRedirect( $pageType, $leaveRedirect );
			$ops = [];
			foreach ( $pages as $from ) {
				$to = $pageTitleRenamer->getNewTitle( $from );
				$op = new PageMoveOperation( $from, $to );

				if ( $pageType === 'pt-movepage-list-other' ) {
					// TODO: In the future, think of moving all checks regarding whether a page
					// is movable into this method. Currently its' being checked in two places
					// making things slow.
					$movePage = $movePageFactory->newMovePage( $from, $to );
					$status = $movePage->isValidMove();

					// Remove non movable subpages
					if ( !$status->isOK() ) {
						$nonMovableSubpages[ $from->getPrefixedText() ] = $status;
						continue;
					}
				}

				$op->setLeaveRedirect( $leaveRedirect );

				$talkPage = $talkPages[ $from->getPrefixedDBkey() ] ?? null;
				if ( $talkPage ) {
					$op->setTalkpage( $talkPage, $pageTitleRenamer->getNewTitle( $talkPage ) );
				}
				$ops[] = $op;
			}

				return $ops;
		};

		return new PageMoveCollection(
			$createOps( [ $source ], 'pt-movepage-list-source' )[0],
			$createOps( $classifiedSubpages['translationPages'], 'pt-movepage-list-translation' ),
			$createOps( $classifiedSubpages['translationUnitPages'], 'pt-movepage-list-section' ),
			$createOps( $subpages, 'pt-movepage-list-other' ),
			$nonMovableSubpages,
			$relatedTranslatablePageList
		);
	}

	/** @param string[] $titles */
	private function lock( array $titles ): void {
		$data = [];
		foreach ( $titles as $title ) {
			$data[$this->cache->makeKey( 'pt-lock', sha1( $title ) )] = 'locked';
		}

		// Do not lock pages indefinitely during translatable page moves since
		// they can fail. Add a timeout so that the locks expire by themselves.
		// Timeout value has been chosen by a gut feeling
		$this->cache->setMulti( $data, self::LOCK_TIMEOUT );
	}

	/** @param string[] $titles */
	private function unlock( array $titles ): void {
		foreach ( $titles as $title ) {
			$this->cache->delete( $this->cache->makeKey( 'pt-lock', sha1( $title ) ) );
		}
	}

	/**
	 * @param TranslatableBundle $sourceBundle
	 * @param User $performer
	 * @param string[] $pagesToMove
	 * @param array<string,bool> $pagesToRedirect
	 * @param string $reason
	 * @param ?callable $progressCallback
	 */
	private function move(
		TranslatableBundle $sourceBundle,
		User $performer,
		array $pagesToMove,
		array $pagesToRedirect,
		string $reason,
		?callable $progressCallback = null
	): void {
		$fuzzyBot = FuzzyBot::getUser();

		Hooks::$allowTargetEdit = true;

		$processed = 0;

		$this->dbProvider->getPrimaryDatabase()->startAtomic( __METHOD__ );
		foreach ( $pagesToMove as $source => $target ) {
			$sourceTitle = Title::newFromText( $source );
			$targetTitle = Title::newFromText( $target );

			if ( $source === $sourceBundle->getTitle()->getPrefixedText() ) {
				$user = $performer;
				$moveSummary = $reason;
			} else {
				$user = $fuzzyBot;
				$moveSummary = wfMessage(
					'pt-movepage-logreason', $sourceBundle->getTitle()->getPrefixedText()
				)->text();
			}

			$mover = $this->movePageFactory->newMovePage( $sourceTitle, $targetTitle );
			$status = $mover->move( $user, $moveSummary, $pagesToRedirect[$source] ?? false );
			$processed++;

			if ( $progressCallback ) {
				$progressCallback(
					$sourceTitle,
					$targetTitle,
					$status,
					count( $pagesToMove ),
					$processed
				);
			}

			if ( !$status->isOK() ) {
				$this->bundleFactory->getPageMoveLogger( $sourceBundle )
					->logError( $performer, $sourceTitle, $targetTitle, $status );
			}

			$this->unlock( [ $source, $target ] );
		}
		$this->dbProvider->getPrimaryDatabase()->endAtomic( __METHOD__ );

		Hooks::$allowTargetEdit = false;
	}

	private function getRenameMoveBlocker( Title $old, string $pageType, int $renameError ): Status {
		if ( $renameError === PageTitleRenamer::NO_ERROR ) {
			throw new LogicException(
				'Trying to fetch MoveBlocker when there was no error during rename. Title: ' .
				$old->getPrefixedText() . ', page type: ' . $pageType
			);
		}

		if ( $renameError === PageTitleRenamer::UNKNOWN_PAGE ) {
			$status = Status::newFatal( 'pt-movepage-block-unknown-page', $old->getPrefixedText() );
		} elseif ( $renameError === PageTitleRenamer::NS_TALK_UNSUPPORTED ) {
			$status = Status::newFatal( 'pt-movepage-block-ns-talk-unsupported', $old->getPrefixedText() );
		} elseif ( $renameError === PageTitleRenamer::RENAME_FAILED ) {
			$status = Status::newFatal( 'pt-movepage-block-rename-failed', $old->getPrefixedText() );
		} else {
			return Status::newFatal( "pt-movepage-block-$pageType-invalid", $old->getPrefixedText() );
		}

		return $status;
	}
}
