<?php

namespace SMW\MediaWiki\Deferred;

use Closure;
use MediaWiki\Deferred\DeferrableUpdate;
use MediaWiki\Deferred\DeferredUpdates;
use Psr\Log\LoggerAwareTrait;
use Wikimedia\Assert\Assert;

/**
 * @see MWCallableUpdate
 *
 * @license GPL-2.0-or-later
 * @since 2.4
 */
class CallableUpdate implements DeferrableUpdate {

	use LoggerAwareTrait;

	/**
	 * Updates that should run before flushing output buffer
	 */
	const STAGE_PRESEND = 'pre';

	/**
	 * Updates that should run after flushing output buffer
	 */
	const STAGE_POSTSEND = 'post';

	/**
	 * @var Closure|callable
	 */
	protected $callback;

	/**
	 * @var bool
	 */
	protected $isDeferrableUpdate = true;

	/**
	 * @var bool
	 */
	protected $isCommandLineMode = false;

	/**
	 * @var bool
	 */
	private $isPending = false;

	/**
	 * @var string
	 */
	private $origin = '';

	/**
	 * @var array
	 */
	private static $pendingUpdates = [];

	/**
	 * @var string|null
	 */
	private $fingerprint = null;

	/**
	 * @var array
	 */
	private static $queueList = [];

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

	/**
	 * @var bool
	 */
	private $catchExceptionAndRethrow = false;

	/**
	 * @since 2.4
	 *
	 * @param callable|null $callback
	 */
	public function __construct( ?callable $callback = null ) {
		if ( $callback === null ) {
			$callback = [ $this, 'emptyCallback' ];
		}

		$this->callback = $callback;
		$this->stage = self::STAGE_POSTSEND;
	}

	/**
	 * @see https://www.mediawiki.org/wiki/Manual:$wgCommandLineMode
	 * Indicates whether MW is running in command-line mode.
	 *
	 * @since 2.5
	 *
	 * @param bool $isCommandLineMode
	 */
	public function isCommandLineMode( $isCommandLineMode ) {
		$this->isCommandLineMode = $isCommandLineMode;
	}

	/**
	 * @since 3.0
	 */
	public function asPresend() {
		$this->stage = self::STAGE_PRESEND;
	}

	/**
	 * @since 3.0
	 *
	 * @return string
	 */
	public function getStage() {
		return $this->stage;
	}

	/**
	 * @since 3.0
	 *
	 * @param callable $callback
	 */
	public function setCallback( callable $callback ) {
		$this->callback = $callback;
	}

	/**
	 * @deprecated since 3.0, use DeferredCallableUpdate::isDeferrableUpdate
	 * @since 2.4
	 */
	public function enabledDeferredUpdate( $enabledDeferredUpdate = true ) {
		$this->isDeferrableUpdate( $enabledDeferredUpdate );
	}

	/**
	 * @note Unit/Integration tests in MW 1.26- showed ambiguous behaviour when
	 * run in deferred mode because not all MW operations were supporting late
	 * execution.
	 *
	 * @since 3.0
	 */
	public function isDeferrableUpdate( $isDeferrableUpdate ) {
		$this->isDeferrableUpdate = (bool)$isDeferrableUpdate;
	}

	/**
	 * @note MW 1.34+ showed a behaviour where exceptions during a deferred update
	 * weren't logged and reported hereby allowing errors to go unnoticed forcing
	 * a possible rollback without a chance to investigate missing data updates.
	 *
	 * @since 3.1
	 *
	 * @param bool $catchExceptionAndRethrow
	 */
	public function catchExceptionAndRethrow( $catchExceptionAndRethrow ) {
		$this->catchExceptionAndRethrow = (bool)$catchExceptionAndRethrow;
	}

	/**
	 * @note If wgCommandLineMode = true (e.g. MW is in CLI mode) then
	 * DeferredUpdates::addUpdate pushes updates directly into execution mode
	 * which may not be desirable for all update processes therefore hold on to it
	 * by using an internal waitableUpdate list and release them at convenience.
	 *
	 * @since 2.4
	 *
	 * @param booloan $isPending
	 */
	public function markAsPending( $isPending = false ) {
		$this->isPending = (bool)$isPending;
	}

	/**
	 * @note Set a fingerprint allowing it to track and detect duplicate update
	 * requests while being unprocessed.
	 *
	 * @since 2.5
	 *
	 * @param string|null $fingerprint
	 */
	public function setFingerprint( $fingerprint = null ) {
		$this->fingerprint = md5( $fingerprint ?? '' );
	}

	/**
	 * @since 3.0
	 *
	 * @param string|null $queue
	 */
	public function getFingerprint() {
		return $this->fingerprint;
	}

	/**
	 * @since 2.5
	 *
	 * @param string $origin
	 */
	public function setOrigin( $origin ) {
		$this->origin = $origin;
	}

	/**
	 * @see DeferrableCallback::getOrigin
	 *
	 * @since 2.5
	 *
	 * @return string
	 */
	public function getOrigin() {
		if ( is_string( $this->origin ) ) {
			$this->origin = [ $this->origin ];
		}

		return json_encode( $this->origin );
	}

	/**
	 * Release all pending updates into MediaWiki's DeferredUpdates stack.
	 * @since 2.4
	 */
	public static function releasePendingUpdates() {
		foreach ( self::$pendingUpdates as $update ) {
			DeferredUpdates::addUpdate( $update );
		}

		self::$pendingUpdates = [];
	}

	/**
	 * Clear all pending updates without executing them. Useful for tests.
	 * @since 4.2
	 */
	public static function clearPendingUpdates() {
		Assert::precondition( defined( 'MW_PHPUNIT_TEST' ), 'This method should only be called in tests' );
		self::$pendingUpdates = [];
	}

	/**
	 * @see DeferrableUpdate::doUpdate
	 *
	 * @since 2.4
	 */
	public function doUpdate() {
		if ( $this->catchExceptionAndRethrow ) {
			$this->attemptUpdate();
		} else {
			$this->runUpdate();
		}

		$this->logger->info(
			[ 'DeferrableUpdate', 'Update completed: {origin} (fingerprint:{fingerprint})' ],
			[ 'method' => __METHOD__, 'role' => 'developer', 'origin' => $this->getOrigin(), 'fingerprint' => $this->fingerprint ]
		);
	}

	/**
	 * @since 2.5
	 */
	public function pushUpdate() {
		if ( $this->fingerprint !== null && isset( self::$queueList[$this->fingerprint] ) ) {
			$this->logger->info(
				[ 'DeferrableUpdate', 'Push: {origin} (fingerprint: {fingerprint} is already listed, skip)' ],
				[ 'method' => __METHOD__, 'role' => 'developer', 'origin' => $this->getOrigin(), 'fingerprint' => $this->fingerprint ]
			);
			return;
		}

		self::$queueList[$this->fingerprint] = true;

		if ( $this->isPending && $this->isDeferrableUpdate ) {

			$this->logger->info(
				[ 'DeferrableUpdate', 'Push: {origin} (as pending DeferredCallableUpdate)' ],
				[ 'method' => __METHOD__, 'role' => 'developer', 'origin' => $this->getOrigin(), 'fingerprint' => $this->fingerprint ]
			);

			return self::$pendingUpdates[] = $this;
		}

		if ( !$this->isCommandLineMode && $this->isDeferrableUpdate ) {
			return $this->registerUpdate( $this );
		}

		$this->doUpdate();
	}

	protected function registerUpdate( $update ) {
		$this->logger->info(
			[ 'DeferrableUpdate', 'Added: {ctx}' ],
			[ 'method' => __METHOD__, 'role' => 'developer', 'ctx' => $this->loggableContext() ]
		);

		$stage = null;

		if ( $update->getStage() === self::STAGE_POSTSEND && defined( 'DeferredUpdates::POSTSEND' ) ) {
			$stage = DeferredUpdates::POSTSEND;
		}

		if ( $update->getStage() === self::STAGE_PRESEND && defined( 'DeferredUpdates::PRESEND' ) ) {
			$stage = DeferredUpdates::PRESEND;
		}

		DeferredUpdates::addUpdate( $update, $stage );
	}

	protected function loggableContext() {
		return [ 'origin' => $this->origin, 'fingerprint' => $this->fingerprint, 'stage' => $this->stage ];
	}

	protected function emptyCallback() {
		$this->logger->info(
			[ 'DeferrableUpdate', 'Empty callback!' ],
			[ 'role' => 'developer', 'method' => __METHOD__ ]
		);
	}

	private function attemptUpdate() {
		$e = null;

		try {
			$this->runUpdate();
		} catch ( \Exception $e ) {
		} catch ( \Throwable $e ) {
		}

		if ( $e === null ) {
			return;
		}

		$error = [
			'type' => $this->getOrigin(),
			'message' => $e->getMessage(),
			'trace' => $e->getTraceAsString()
		];

		// Log and re-throw
		$this->logger->error(
			[ "Deferred update {type} failed: {message}\n{trace}" ],
			[ 'role' => 'production' ] + $error
		);

		throw $e;
	}

	private function runUpdate() {
		( $this->callback )();
		unset( self::$queueList[$this->fingerprint] );
	}

}
