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

use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\FileRepo\File\FileSelectQueryBuilder;
use MediaWiki\MediaWikiServices;
use MediaWiki\Status\Status;
use Wikimedia\Rdbms\SelectQueryBuilder;
use Wikimedia\ScopedCallback;

/**
 * Helper class for file undeletion
 *
 * @internal
 * @ingroup FileAbstraction
 */
class LocalFileRestoreBatch {
	/** @var LocalFile */
	private $file;

	/** @var string[] List of file IDs to restore */
	private $cleanupBatch;

	/** @var int[] List of file IDs to restore */
	private $ids;

	/** @var bool Add all revisions of the file */
	private $all;

	/** @var bool Whether to remove all settings for suppressed fields */
	private $unsuppress;

	/**
	 * @param LocalFile $file
	 * @param bool $unsuppress
	 */
	public function __construct( LocalFile $file, $unsuppress = false ) {
		$this->file = $file;
		$this->cleanupBatch = [];
		$this->ids = [];
		$this->unsuppress = $unsuppress;
	}

	/**
	 * Add a file by ID
	 * @param int $fa_id
	 */
	public function addId( $fa_id ) {
		$this->ids[] = $fa_id;
	}

	/**
	 * Add a whole lot of files by ID
	 * @param int[] $ids
	 */
	public function addIds( $ids ) {
		$this->ids = array_merge( $this->ids, $ids );
	}

	/**
	 * Add all revisions of the file
	 */
	public function addAll() {
		$this->all = true;
	}

	/**
	 * Run the transaction, except the cleanup batch.
	 * The cleanup batch should be run in a separate transaction, because it locks different
	 * rows and there's no need to keep the image row locked while it's acquiring those locks
	 * The caller may have its own transaction open.
	 * So we save the batch and let the caller call cleanup()
	 * @return Status
	 */
	public function execute() {
		/** @var Language $wgLang */
		global $wgLang;

		$repo = $this->file->getRepo();
		if ( !$this->all && !$this->ids ) {
			// Do nothing
			return $repo->newGood();
		}

		$status = $this->file->acquireFileLock();
		if ( !$status->isOK() ) {
			return $status;
		}

		$dbw = $this->file->repo->getPrimaryDB();

		$ownTrx = !$dbw->trxLevel();
		$funcName = __METHOD__;
		$dbw->startAtomic( __METHOD__ );

		$unlockScope = new ScopedCallback( function () use ( $dbw, $funcName ) {
			$dbw->endAtomic( $funcName );
			$this->file->releaseFileLock();
		} );

		$commentStore = MediaWikiServices::getInstance()->getCommentStore();

		$status = $this->file->repo->newGood();

		$queryBuilder = $dbw->newSelectQueryBuilder()
			->select( '1' )
			->from( 'image' )
			->where( [ 'img_name' => $this->file->getName() ] );
		// The acquireFileLock() should already prevent changes, but this still may need
		// to bypass any transaction snapshot. However, if we started the
		// trx (which we probably did) then snapshot is post-lock and up-to-date.
		if ( !$ownTrx ) {
			$queryBuilder->lockInShareMode();
		}
		$exists = (bool)$queryBuilder->caller( __METHOD__ )->fetchField();

		// Fetch all or selected archived revisions for the file,
		// sorted from the most recent to the oldest.
		$arQueryBuilder = FileSelectQueryBuilder::newForArchivedFile( $dbw );
		$arQueryBuilder->where( [ 'fa_name' => $this->file->getName() ] )
			->orderBy( 'fa_timestamp', SelectQueryBuilder::SORT_DESC );

		if ( !$this->all ) {
			$arQueryBuilder->andWhere( [ 'fa_id' => $this->ids ] );
		}

		$result = $arQueryBuilder->caller( __METHOD__ )->fetchResultSet();

		$idsPresent = [];
		$storeBatch = [];
		$insertBatch = [];
		$insertCurrent = false;
		$deleteIds = [];
		$first = true;
		$archiveNames = [];

		foreach ( $result as $row ) {
			$idsPresent[] = $row->fa_id;

			if ( $row->fa_name != $this->file->getName() ) {
				$status->error( 'undelete-filename-mismatch', $wgLang->timeanddate( $row->fa_timestamp ) );
				$status->failCount++;
				continue;
			}

			if ( $row->fa_storage_key == '' ) {
				// Revision was missing pre-deletion
				$status->error( 'undelete-bad-store-key', $wgLang->timeanddate( $row->fa_timestamp ) );
				$status->failCount++;
				continue;
			}

			$deletedRel = $repo->getDeletedHashPath( $row->fa_storage_key ) .
				$row->fa_storage_key;
			$deletedUrl = $repo->getVirtualUrl() . '/deleted/' . $deletedRel;

			if ( isset( $row->fa_sha1 ) ) {
				$sha1 = $row->fa_sha1;
			} else {
				// old row, populate from key
				$sha1 = LocalRepo::getHashFromKey( $row->fa_storage_key );
			}

			# Fix leading zero
			if ( strlen( $sha1 ) == 32 && $sha1[0] == '0' ) {
				$sha1 = substr( $sha1, 1 );
			}

			if ( $row->fa_major_mime === null || $row->fa_major_mime == 'unknown'
				|| $row->fa_minor_mime === null || $row->fa_minor_mime == 'unknown'
				|| $row->fa_media_type === null || $row->fa_media_type == 'UNKNOWN'
				|| $row->fa_metadata === null
			) {
				// Refresh our metadata
				// Required for a new current revision; nice for older ones too. :)
				$this->file->loadFromFile( $deletedUrl );
				$mime = $this->file->getMimeType();
				[ $majorMime, $minorMime ] = File::splitMime( $mime );
				$mediaInfo = [
					'minor_mime' => $minorMime,
					'major_mime' => $majorMime,
					'media_type' => $this->file->getMediaType(),
					'metadata' => $this->file->getMetadataForDb( $dbw )
				];
			} else {
				$mediaInfo = [
					'minor_mime' => $row->fa_minor_mime,
					'major_mime' => $row->fa_major_mime,
					'media_type' => $row->fa_media_type,
					'metadata' => $row->fa_metadata
				];
			}

			$comment = $commentStore->getComment( 'fa_description', $row );
			if ( $first && !$exists ) {
				// This revision will be published as the new current version
				$destRel = $this->file->getRel();
				$commentFields = $commentStore->insert( $dbw, 'img_description', $comment );
				$insertCurrent = [
					'img_name' => $row->fa_name,
					'img_size' => $row->fa_size,
					'img_width' => $row->fa_width,
					'img_height' => $row->fa_height,
					'img_metadata' => $mediaInfo['metadata'],
					'img_bits' => $row->fa_bits,
					'img_media_type' => $mediaInfo['media_type'],
					'img_major_mime' => $mediaInfo['major_mime'],
					'img_minor_mime' => $mediaInfo['minor_mime'],
					'img_actor' => $row->fa_actor,
					'img_timestamp' => $row->fa_timestamp,
					'img_sha1' => $sha1
				] + $commentFields;

				// The live (current) version cannot be hidden!
				if ( !$this->unsuppress && $row->fa_deleted ) {
					$status->fatal( 'undeleterevdel' );
					return $status;
				}
			} else {
				$archiveName = $row->fa_archive_name;

				if ( $archiveName === null ) {
					// This was originally a current version; we
					// have to devise a new archive name for it.
					// Format is <timestamp of archiving>!<name>
					$timestamp = (int)wfTimestamp( TS_UNIX, $row->fa_deleted_timestamp );

					do {
						$archiveName = wfTimestamp( TS_MW, $timestamp ) . '!' . $row->fa_name;
						$timestamp++;
					} while ( isset( $archiveNames[$archiveName] ) );
				}

				$archiveNames[$archiveName] = true;
				$destRel = $this->file->getArchiveRel( $archiveName );
				$insertBatch[] = [
					'oi_name' => $row->fa_name,
					'oi_archive_name' => $archiveName,
					'oi_size' => $row->fa_size,
					'oi_width' => $row->fa_width,
					'oi_height' => $row->fa_height,
					'oi_bits' => $row->fa_bits,
					'oi_actor' => $row->fa_actor,
					'oi_timestamp' => $row->fa_timestamp,
					'oi_metadata' => $mediaInfo['metadata'],
					'oi_media_type' => $mediaInfo['media_type'],
					'oi_major_mime' => $mediaInfo['major_mime'],
					'oi_minor_mime' => $mediaInfo['minor_mime'],
					'oi_deleted' => $this->unsuppress ? 0 : $row->fa_deleted,
					'oi_sha1' => $sha1
				] + $commentStore->insert( $dbw, 'oi_description', $comment );
			}

			$deleteIds[] = $row->fa_id;

			if ( !$this->unsuppress && $row->fa_deleted & File::DELETED_FILE ) {
				// private files can stay where they are
				$status->successCount++;
			} else {
				$storeBatch[] = [ $deletedUrl, 'public', $destRel ];
				$this->cleanupBatch[] = $row->fa_storage_key;
			}

			$first = false;
		}

		unset( $result );

		// Add a warning to the status object for missing IDs
		$missingIds = array_diff( $this->ids, $idsPresent );

		foreach ( $missingIds as $id ) {
			$status->error( 'undelete-missing-filearchive', $id );
		}

		if ( !$repo->hasSha1Storage() ) {
			// Remove missing files from batch, so we don't get errors when undeleting them
			$checkStatus = $this->removeNonexistentFiles( $storeBatch );
			if ( !$checkStatus->isGood() ) {
				$status->merge( $checkStatus );
				return $status;
			}
			$storeBatch = $checkStatus->value;

			// Run the store batch
			// Use the OVERWRITE_SAME flag to smooth over a common error
			$storeStatus = $this->file->repo->storeBatch( $storeBatch, FileRepo::OVERWRITE_SAME );
			$status->merge( $storeStatus );

			if ( !$status->isGood() ) {
				// Even if some files could be copied, fail entirely as that is the
				// easiest thing to do without data loss
				$this->cleanupFailedBatch( $storeStatus, $storeBatch );
				$status->setOK( false );
				return $status;
			}
		}

		// Run the DB updates
		// Because we have locked the image row, key conflicts should be rare.
		// If they do occur, we can roll back the transaction at this time with
		// no data loss, but leaving unregistered files scattered throughout the
		// public zone.
		// This is not ideal, which is why it's important to lock the image row.
		if ( $insertCurrent ) {
			$dbw->newInsertQueryBuilder()
				->insertInto( 'image' )
				->row( $insertCurrent )
				->caller( __METHOD__ )->execute();
		}

		if ( $insertBatch ) {
			$dbw->newInsertQueryBuilder()
				->insertInto( 'oldimage' )
				->rows( $insertBatch )
				->caller( __METHOD__ )->execute();
		}

		if ( $deleteIds ) {
			$dbw->newDeleteQueryBuilder()
				->deleteFrom( 'filearchive' )
				->where( [ 'fa_id' => $deleteIds ] )
				->caller( __METHOD__ )->execute();
		}

		// If store batch is empty (all files are missing), deletion is to be considered successful
		if ( $status->successCount > 0 || !$storeBatch || $repo->hasSha1Storage() ) {
			if ( !$exists ) {
				wfDebug( __METHOD__ . " restored {$status->successCount} items, creating a new current" );

				DeferredUpdates::addUpdate( SiteStatsUpdate::factory( [ 'images' => 1 ] ) );

				$this->file->purgeEverything();
			} else {
				wfDebug( __METHOD__ . " restored {$status->successCount} as archived versions" );
				$this->file->purgeDescription();
			}
		}

		ScopedCallback::consume( $unlockScope );

		return $status;
	}

	/**
	 * Removes non-existent files from a store batch.
	 * @param array[] $triplets
	 * @return Status
	 */
	protected function removeNonexistentFiles( $triplets ) {
		$files = $filteredTriplets = [];
		foreach ( $triplets as $file ) {
			$files[$file[0]] = $file[0];
		}

		$result = $this->file->repo->fileExistsBatch( $files );
		if ( in_array( null, $result, true ) ) {
			return Status::newFatal( 'backend-fail-internal',
				$this->file->repo->getBackend()->getName() );
		}

		foreach ( $triplets as $file ) {
			if ( $result[$file[0]] ) {
				$filteredTriplets[] = $file;
			}
		}

		return Status::newGood( $filteredTriplets );
	}

	/**
	 * Removes non-existent files from a cleanup batch.
	 * @param string[] $batch
	 * @return string[]
	 */
	protected function removeNonexistentFromCleanup( $batch ) {
		$files = $newBatch = [];
		$repo = $this->file->repo;

		foreach ( $batch as $file ) {
			$files[$file] = $repo->getVirtualUrl( 'deleted' ) . '/' .
				rawurlencode( $repo->getDeletedHashPath( $file ) . $file );
		}

		$result = $repo->fileExistsBatch( $files );

		foreach ( $batch as $file ) {
			if ( $result[$file] ) {
				$newBatch[] = $file;
			}
		}

		return $newBatch;
	}

	/**
	 * Delete unused files in the deleted zone.
	 * This should be called from outside the transaction in which execute() was called.
	 * @return Status
	 */
	public function cleanup() {
		if ( !$this->cleanupBatch ) {
			return $this->file->repo->newGood();
		}

		$this->cleanupBatch = $this->removeNonexistentFromCleanup( $this->cleanupBatch );

		$status = $this->file->repo->cleanupDeletedBatch( $this->cleanupBatch );

		return $status;
	}

	/**
	 * Cleanup a failed batch. The batch was only partially successful, so
	 * rollback by removing all items that were successfully copied.
	 *
	 * @param Status $storeStatus
	 * @param array[] $storeBatch
	 */
	protected function cleanupFailedBatch( $storeStatus, $storeBatch ) {
		$cleanupBatch = [];

		foreach ( $storeStatus->success as $i => $success ) {
			// Check if this item of the batch was successfully copied
			if ( $success ) {
				// Item was successfully copied and needs to be removed again
				// Extract ($dstZone, $dstRel) from the batch
				$cleanupBatch[] = [ $storeBatch[$i][1], $storeBatch[$i][2] ];
			}
		}
		$this->file->repo->cleanupBatch( $cleanupBatch );
	}
}
