<?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\FileRepo\File\FileSelectQueryBuilder;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Status\Status;
use MediaWiki\User\UserIdentity;
use Wikimedia\ScopedCallback;

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

	/** @var string */
	private $reason;

	/** @var array */
	private $srcRels = [];

	/** @var array */
	private $archiveUrls = [];

	/** @var array[] Items to be processed in the deletion batch */
	private $deletionBatch;

	/** @var bool Whether to suppress all suppressable fields when deleting */
	private $suppress;

	/** @var UserIdentity */
	private $user;

	/**
	 * @param File $file
	 * @param UserIdentity $user
	 * @param string $reason
	 * @param bool $suppress
	 */
	public function __construct(
		File $file,
		UserIdentity $user,
		$reason = '',
		$suppress = false
	) {
		$this->file = $file;
		$this->user = $user;
		$this->reason = $reason;
		$this->suppress = $suppress;
	}

	public function addCurrent() {
		$this->srcRels['.'] = $this->file->getRel();
	}

	/**
	 * @param string $oldName
	 */
	public function addOld( $oldName ) {
		$this->srcRels[$oldName] = $this->file->getArchiveRel( $oldName );
		$this->archiveUrls[] = $this->file->getArchiveUrl( $oldName );
	}

	/**
	 * Add the old versions of the image to the batch
	 * @return string[] List of archive names from old versions
	 */
	public function addOlds() {
		$archiveNames = [];

		$dbw = $this->file->repo->getPrimaryDB();
		$result = $dbw->newSelectQueryBuilder()
			->select( [ 'oi_archive_name' ] )
			->from( 'oldimage' )
			->where( [ 'oi_name' => $this->file->getName() ] )
			->caller( __METHOD__ )->fetchResultSet();

		foreach ( $result as $row ) {
			$this->addOld( $row->oi_archive_name );
			$archiveNames[] = $row->oi_archive_name;
		}

		return $archiveNames;
	}

	/**
	 * @return array
	 */
	protected function getOldRels() {
		if ( !isset( $this->srcRels['.'] ) ) {
			$oldRels =& $this->srcRels;
			$deleteCurrent = false;
		} else {
			$oldRels = $this->srcRels;
			unset( $oldRels['.'] );
			$deleteCurrent = true;
		}

		return [ $oldRels, $deleteCurrent ];
	}

	/**
	 * @param StatusValue $status To add error messages to
	 * @return array
	 */
	protected function getHashes( StatusValue $status ): array {
		$hashes = [];
		[ $oldRels, $deleteCurrent ] = $this->getOldRels();

		if ( $deleteCurrent ) {
			$hashes['.'] = $this->file->getSha1();
		}

		if ( count( $oldRels ) ) {
			$dbw = $this->file->repo->getPrimaryDB();
			$res = $dbw->newSelectQueryBuilder()
				->select( [ 'oi_archive_name', 'oi_sha1' ] )
				->from( 'oldimage' )
				->where( [
					'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ),
					'oi_name' => $this->file->getName() // performance
				] )
				->caller( __METHOD__ )->fetchResultSet();

			foreach ( $res as $row ) {
				if ( $row->oi_archive_name === '' ) {
					// File lost, the check simulates OldLocalFile::exists
					$hashes[$row->oi_archive_name] = false;
					continue;
				}
				if ( rtrim( $row->oi_sha1, "\0" ) === '' ) {
					// Get the hash from the file
					$oldUrl = $this->file->getArchiveVirtualUrl( $row->oi_archive_name );
					$props = $this->file->repo->getFileProps( $oldUrl );

					if ( $props['fileExists'] ) {
						// Upgrade the oldimage row
						$dbw->newUpdateQueryBuilder()
							->update( 'oldimage' )
							->set( [ 'oi_sha1' => $props['sha1'] ] )
							->where( [
								'oi_name' => $this->file->getName(),
								'oi_archive_name' => $row->oi_archive_name,
							] )
							->caller( __METHOD__ )->execute();
						$hashes[$row->oi_archive_name] = $props['sha1'];
					} else {
						$hashes[$row->oi_archive_name] = false;
					}
				} else {
					$hashes[$row->oi_archive_name] = $row->oi_sha1;
				}
			}
		}

		$missing = array_diff_key( $this->srcRels, $hashes );

		foreach ( $missing as $name => $rel ) {
			$status->error( 'filedelete-old-unregistered', $name );
		}

		foreach ( $hashes as $name => $hash ) {
			if ( !$hash ) {
				$status->error( 'filedelete-missing', $this->srcRels[$name] );
				unset( $hashes[$name] );
			}
		}

		return $hashes;
	}

	protected function doDBInserts() {
		$now = time();
		$dbw = $this->file->repo->getPrimaryDB();

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

		$encTimestamp = $dbw->addQuotes( $dbw->timestamp( $now ) );
		$encUserId = $dbw->addQuotes( $this->user->getId() );
		$encGroup = $dbw->addQuotes( 'deleted' );
		$ext = $this->file->getExtension();
		$dotExt = $ext === '' ? '' : ".$ext";
		$encExt = $dbw->addQuotes( $dotExt );
		[ $oldRels, $deleteCurrent ] = $this->getOldRels();

		// Bitfields to further suppress the content
		if ( $this->suppress ) {
			$bitfield = RevisionRecord::SUPPRESSED_ALL;
		} else {
			$bitfield = 'oi_deleted';
		}

		if ( $deleteCurrent ) {
			$tables = [ 'image' ];
			$fields = [
				'fa_storage_group' => $encGroup,
				'fa_storage_key' => $dbw->conditional(
					[ 'img_sha1' => '' ],
					$dbw->addQuotes( '' ),
					$dbw->buildConcat( [ "img_sha1", $encExt ] )
				),
				'fa_deleted_user' => $encUserId,
				'fa_deleted_timestamp' => $encTimestamp,
				'fa_deleted' => $this->suppress ? $bitfield : 0,
				'fa_name' => 'img_name',
				'fa_archive_name' => 'NULL',
				'fa_size' => 'img_size',
				'fa_width' => 'img_width',
				'fa_height' => 'img_height',
				'fa_metadata' => 'img_metadata',
				'fa_bits' => 'img_bits',
				'fa_media_type' => 'img_media_type',
				'fa_major_mime' => 'img_major_mime',
				'fa_minor_mime' => 'img_minor_mime',
				'fa_description_id' => 'img_description_id',
				'fa_timestamp' => 'img_timestamp',
				'fa_sha1' => 'img_sha1',
				'fa_actor' => 'img_actor',
			];
			$joins = [];

			$fields += array_map(
				[ $dbw, 'addQuotes' ],
				$commentStore->insert( $dbw, 'fa_deleted_reason', $this->reason )
			);

			$dbw->insertSelect( 'filearchive', $tables, $fields,
				[ 'img_name' => $this->file->getName() ], __METHOD__, [ 'IGNORE' ], [], $joins );
		}

		if ( count( $oldRels ) ) {
			$queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbw );
			$queryBuilder
				->forUpdate()
				->where( [ 'oi_name' => $this->file->getName() ] )
				->andWhere( [ 'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) ) ] );
			$res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
			$rowsInsert = [];
			if ( $res->numRows() ) {
				$reason = $commentStore->createComment( $dbw, $this->reason );
				foreach ( $res as $row ) {
					$comment = $commentStore->getComment( 'oi_description', $row );
					$rowsInsert[] = [
						// Deletion-specific fields
						'fa_storage_group' => 'deleted',
						'fa_storage_key' => ( $row->oi_sha1 === '' )
						? ''
						: "{$row->oi_sha1}{$dotExt}",
						'fa_deleted_user' => $this->user->getId(),
						'fa_deleted_timestamp' => $dbw->timestamp( $now ),
						// Counterpart fields
						'fa_deleted' => $this->suppress ? $bitfield : $row->oi_deleted,
						'fa_name' => $row->oi_name,
						'fa_archive_name' => $row->oi_archive_name,
						'fa_size' => $row->oi_size,
						'fa_width' => $row->oi_width,
						'fa_height' => $row->oi_height,
						'fa_metadata' => $row->oi_metadata,
						'fa_bits' => $row->oi_bits,
						'fa_media_type' => $row->oi_media_type,
						'fa_major_mime' => $row->oi_major_mime,
						'fa_minor_mime' => $row->oi_minor_mime,
						'fa_actor' => $row->oi_actor,
						'fa_timestamp' => $row->oi_timestamp,
						'fa_sha1' => $row->oi_sha1
					] + $commentStore->insert( $dbw, 'fa_deleted_reason', $reason )
					+ $commentStore->insert( $dbw, 'fa_description', $comment );
				}
			}

			$dbw->newInsertQueryBuilder()
				->insertInto( 'filearchive' )
				->ignore()
				->rows( $rowsInsert )
				->caller( __METHOD__ )->execute();
		}
	}

	private function doDBDeletes() {
		$dbw = $this->file->repo->getPrimaryDB();
		[ $oldRels, $deleteCurrent ] = $this->getOldRels();

		if ( count( $oldRels ) ) {
			$dbw->newDeleteQueryBuilder()
				->deleteFrom( 'oldimage' )
				->where( [
					'oi_name' => $this->file->getName(),
					'oi_archive_name' => array_map( 'strval', array_keys( $oldRels ) )
				] )
				->caller( __METHOD__ )->execute();
		}

		if ( $deleteCurrent ) {
			$dbw->newDeleteQueryBuilder()
				->deleteFrom( 'image' )
				->where( [ 'img_name' => $this->file->getName() ] )
				->caller( __METHOD__ )->execute();
		}
	}

	/**
	 * Run the transaction
	 * @return Status
	 */
	public function execute() {
		$repo = $this->file->getRepo();
		$lockStatus = $this->file->acquireFileLock();
		if ( !$lockStatus->isOK() ) {
			return $lockStatus;
		}
		$unlockScope = new ScopedCallback( function () {
			$this->file->releaseFileLock();
		} );

		$status = $this->file->repo->newGood();
		// Prepare deletion batch
		$hashes = $this->getHashes( $status );
		$this->deletionBatch = [];
		$ext = $this->file->getExtension();
		$dotExt = $ext === '' ? '' : ".$ext";

		foreach ( $this->srcRels as $name => $srcRel ) {
			// Skip files that have no hash (e.g. missing DB record, or sha1 field and file source)
			if ( isset( $hashes[$name] ) ) {
				$hash = $hashes[$name];
				$key = $hash . $dotExt;
				$dstRel = $repo->getDeletedHashPath( $key ) . $key;
				$this->deletionBatch[$name] = [ $srcRel, $dstRel ];
			}
		}

		if ( !$repo->hasSha1Storage() ) {
			// Removes non-existent file from the batch, so we don't get errors.
			// This also handles files in the 'deleted' zone deleted via revision deletion.
			$checkStatus = $this->removeNonexistentFiles( $this->deletionBatch );
			if ( !$checkStatus->isGood() ) {
				$status->merge( $checkStatus );
				return $status;
			}
			$this->deletionBatch = $checkStatus->value;

			// Execute the file deletion batch
			$status = $this->file->repo->deleteBatch( $this->deletionBatch );
			if ( !$status->isGood() ) {
				$status->merge( $status );
			}
		}

		if ( !$status->isOK() ) {
			// Critical file deletion error; abort
			return $status;
		}

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

		$dbw->startAtomic( __METHOD__ );

		// Copy the image/oldimage rows to filearchive
		$this->doDBInserts();
		// Delete image/oldimage rows
		$this->doDBDeletes();

		// This is typically a no-op since we are wrapped by another atomic
		// section in FileDeleteForm and also the implicit transaction.
		$dbw->endAtomic( __METHOD__ );

		// Commit and return
		ScopedCallback::consume( $unlockScope );

		return $status;
	}

	/**
	 * Removes non-existent files from a deletion batch.
	 * @param array[] $batch
	 * @return Status A good status with existing files in $batch as value, or a fatal status in case of I/O errors.
	 */
	protected function removeNonexistentFiles( $batch ) {
		$files = [];

		foreach ( $batch as [ $src, /* dest */ ] ) {
			$files[$src] = $this->file->repo->getVirtualUrl( 'public' ) . '/' . rawurlencode( $src );
		}

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

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

		return Status::newGood( $newBatch );
	}
}
