<?php
/**
 * Contains a class to track changes to the messages when importing messages from remote source.
 * @author Abijeet Patro
 * @license GPL-2.0-or-later
 * @file
 */

namespace MediaWiki\Extension\Translate\MessageSync;

use InvalidArgumentException;

/**
 * Class is used to track the changes made when importing messages from the remote sources
 * using importExternalTranslations.php. Also provides an interface to query these changes, and
 * update them.
 * @since 2019.10
 */
class MessageSourceChange {
	/**
	 * @var array[][][]
	 * @phpcs:ignore Generic.Files.LineLength
	 * @phan-var array<string,array<string,array<string|int,array{key:string,content:string,similarity?:float,matched_to?:string,previous_state?:string}>>>
	 */
	protected $changes = [];
	public const ADDITION = 'addition';
	public const CHANGE = 'change';
	public const DELETION = 'deletion';
	public const RENAME = 'rename';
	public const NONE = 'none';

	private const SIMILARITY_THRESHOLD = 0.9;

	/**
	 * Contains a mapping of message type, and the corresponding addition function
	 * @var callable[]
	 */
	protected $addFunctionMap;
	/**
	 * Contains a mapping of message type, and the corresponding removal function
	 * @var callable[]
	 */
	protected $removeFunctionMap;

	/** @param array[][][] $changes */
	public function __construct( $changes = [] ) {
		$this->changes = $changes;
		$this->addFunctionMap = [
			self::ADDITION => [ $this, 'addAddition' ],
			self::DELETION => [ $this, 'addDeletion' ],
			self::CHANGE => [ $this, 'addChange' ]
		];

		$this->removeFunctionMap = [
			self::ADDITION => [ $this, 'removeAdditions' ],
			self::DELETION => [ $this, 'removeDeletions' ],
			self::CHANGE => [ $this, 'removeChanges' ]
		];
	}

	/**
	 * Add a change under a message group for a specific language
	 * @param string $language
	 * @param string $key
	 * @param string $content
	 */
	public function addChange( $language, $key, $content ) {
		$this->addModification( $language, self::CHANGE, $key, $content );
	}

	/**
	 * Add an addition under a message group for a specific language
	 * @param string $language
	 * @param string $key
	 * @param string $content
	 */
	public function addAddition( $language, $key, $content ) {
		$this->addModification( $language, self::ADDITION, $key, $content );
	}

	/**
	 * Adds a deletion under a message group for a specific language
	 * @param string $language
	 * @param string $key
	 * @param string $content
	 */
	public function addDeletion( $language, $key, $content ) {
		$this->addModification( $language, self::DELETION, $key, $content );
	}

	/**
	 * Adds a rename under a message group for a specific language
	 * @param string $language
	 * @param string[] $addedMessage
	 * @param string[] $deletedMessage
	 * @param float $similarity
	 */
	public function addRename( $language, $addedMessage, $deletedMessage, $similarity = 0 ) {
		$this->changes[$language][self::RENAME][$addedMessage['key']] = [
			'content' => $addedMessage['content'],
			'similarity' => $similarity,
			'matched_to' => $deletedMessage['key'],
			'previous_state' => self::ADDITION,
			'key' => $addedMessage['key']
		];

		$this->changes[$language][self::RENAME][$deletedMessage['key']] = [
			'content' => $deletedMessage['content'],
			'similarity' => $similarity,
			'matched_to' => $addedMessage['key'],
			'previous_state' => self::DELETION,
			'key' => $deletedMessage['key']
		];
	}

	public function setRenameState( $language, $msgKey, $state ) {
		$possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION,
			self::NONE, self::RENAME ];
		if ( !in_array( $state, $possibleStates ) ) {
			throw new InvalidArgumentException(
				"Invalid state passed - '$state'. Possible states - "
				. implode( ', ', $possibleStates )
			);
		}

		$languageChanges = null;
		if ( isset( $this->changes[ $language ] ) ) {
			$languageChanges = &$this->changes[ $language ];
		}
		if ( $languageChanges !== null && isset( $languageChanges[ 'rename' ][ $msgKey ] ) ) {
			$languageChanges[ 'rename' ][ $msgKey ][ 'previous_state' ] = $state;
		}
	}

	/**
	 * @param string $language
	 * @param string $type
	 * @param string $key
	 * @param string $content
	 */
	protected function addModification( $language, $type, $key, $content ) {
		$this->changes[$language][$type][] = [
			'key' => $key,
			'content' => $content,
		];
	}

	/**
	 * Fetch changes for a message group under a language
	 * @param string $language
	 * @return array[]
	 */
	public function getChanges( $language ) {
		return $this->getModification( $language, self::CHANGE );
	}

	/**
	 * Fetch deletions for a message group under a language
	 * @param string $language
	 * @return array[]
	 */
	public function getDeletions( $language ) {
		return $this->getModification( $language, self::DELETION );
	}

	/**
	 * Fetch additions for a message group under a language
	 * @param string $language
	 * @return array[]
	 */
	public function getAdditions( $language ) {
		return $this->getModification( $language, self::ADDITION );
	}

	/**
	 * Finds a message with the given key across different types of modifications.
	 * @param string $language
	 * @param string $key
	 * @param string[] $possibleStates
	 * @param string|null &$modificationType
	 * @return array|null
	 */
	public function findMessage( $language, $key, $possibleStates = [], &$modificationType = null ) {
		$allChanges = [];
		$allChanges[self::ADDITION] = $this->getAdditions( $language );
		$allChanges[self::DELETION] = $this->getDeletions( $language );
		$allChanges[self::CHANGE] = $this->getChanges( $language );
		$allChanges[self::RENAME] = $this->getRenames( $language );

		if ( $possibleStates === [] ) {
			$possibleStates = [ self::ADDITION, self::CHANGE, self::DELETION, self::RENAME ];
		}

		foreach ( $allChanges as $type => $modifications ) {
			if ( !in_array( $type, $possibleStates ) ) {
				continue;
			}

			if ( $type === self::RENAME ) {
				if ( isset( $modifications[$key] ) ) {
					$modificationType = $type;
					return $modifications[$key];
				}
				continue;
			}

			foreach ( $modifications as $modification ) {
				$currentKey = $modification['key'];
				if ( $currentKey === $key ) {
					$modificationType = $type;
					return $modification;
				}
			}
		}

		$modificationType = null;
		return null;
	}

	/**
	 * Break renames, and put messages back into their previous state.
	 * @param string $languageCode
	 * @param string $msgKey
	 * @return string|null previous state of the message
	 */
	public function breakRename( $languageCode, $msgKey ) {
		$msg = $this->findMessage( $languageCode, $msgKey, [ self::RENAME ] );
		if ( $msg === null ) {
			return null;
		}
		$matchedMsg = $this->getMatchedMessage( $languageCode, $msg['key'] );
		if ( $matchedMsg === null ) {
			return null;
		}

		// Remove them from the renames array
		$this->removeRenames( $languageCode, [ $matchedMsg['key'], $msg['key'] ] );

		$matchedMsgState = $matchedMsg[ 'previous_state' ];
		$msgState = $msg[ 'previous_state' ];

		// Add them to the changes under the appropriate state
		if ( $matchedMsgState !== self::NONE ) {
			if ( $matchedMsgState === self::CHANGE ) {
				$matchedMsg['key'] = $msg['key'];
			}
			call_user_func(
				$this->addFunctionMap[ $matchedMsgState ],
				$languageCode,
				$matchedMsg['key'],
				$matchedMsg['content']
			);
		}

		if ( $msgState !== self::NONE ) {
			if ( $msgState === self::CHANGE ) {
				$msg['key'] = $matchedMsg['key'];
			}
			call_user_func(
				$this->addFunctionMap[ $msgState ],
				$languageCode,
				$msg['key'],
				$msg['content']
			);
		}

		return $msgState;
	}

	/**
	 * Fetch renames for a message group under a language
	 * @param string $language
	 * @return array[]
	 */
	public function getRenames( $language ) {
		$renames = $this->getModification( $language, self::RENAME );
		foreach ( $renames as $key => &$rename ) {
			$rename['key'] = $key;
		}

		return $renames;
	}

	/**
	 * @param string $language
	 * @param string $type
	 * @return array[]
	 */
	protected function getModification( $language, $type ) {
		return $this->changes[$language][$type] ?? [];
	}

	/**
	 * Remove additions for a language under the group.
	 * @param string $language
	 * @param array|null $keysToRemove
	 */
	public function removeAdditions( $language, $keysToRemove ) {
		$this->removeModification( $language, self::ADDITION, $keysToRemove );
	}

	/**
	 * Remove deletions for a language under the group.
	 * @param string $language
	 * @param array|null $keysToRemove
	 */
	public function removeDeletions( $language, $keysToRemove ) {
		$this->removeModification( $language, self::DELETION, $keysToRemove );
	}

	/**
	 * Remove changes for a language under the group.
	 * @param string $language
	 * @param array|null $keysToRemove
	 */
	public function removeChanges( $language, $keysToRemove ) {
		$this->removeModification( $language, self::CHANGE, $keysToRemove );
	}

	/**
	 * Remove renames for a language under the group.
	 * @param string $language
	 * @param array|null $keysToRemove
	 */
	public function removeRenames( $language, $keysToRemove ) {
		$this->removeModification( $language, self::RENAME, $keysToRemove );
	}

	/**
	 * Remove modifications based on the type. Avoids usage of ugly if / switch
	 * statement.
	 * @param string $language
	 * @param array $keysToRemove
	 * @param string $type One of ADDITION, CHANGE, DELETION
	 */
	public function removeBasedOnType( $language, $keysToRemove, $type ) {
		$callable = $this->removeFunctionMap[ $type ] ?? null;

		if ( $callable === null ) {
			throw new InvalidArgumentException( 'Type should be one of ' .
				implode( ', ', [ self::ADDITION, self::CHANGE, self::DELETION ] ) .
				". Invalid type $type passed."
			);
		}

		call_user_func( $callable, $language, $keysToRemove );
	}

	/**
	 * Remove all language related changes for a group.
	 * @param string $language
	 */
	public function removeChangesForLanguage( $language ) {
		unset( $this->changes[ $language ] );
	}

	protected function removeModification( $language, $type, $keysToRemove = null ) {
		if ( !isset( $this->changes[$language][$type] ) || $keysToRemove === [] ) {
			return;
		}

		if ( $keysToRemove === null ) {
			unset( $this->changes[$language][$type] );
			return;
		}

		if ( $type === self::RENAME ) {
			$this->changes[$language][$type] =
				array_diff_key( $this->changes[$language][$type], array_flip( $keysToRemove ) );
		} else {
			$this->changes[$language][$type] = array_filter(
				$this->changes[$language][$type],
				static function ( $change ) use ( $keysToRemove ) {
					return !in_array( $change['key'], $keysToRemove, true );
				}
			);
		}
	}

	/**
	 * Return all modifications for the group.
	 * @return array[][][]
	 */
	public function getAllModifications() {
		return $this->changes;
	}

	/**
	 * Get all for a language under the group.
	 * @param string $language
	 * @return array[][]
	 */
	public function getModificationsForLanguage( $language ) {
		return $this->changes[$language] ?? [];
	}

	/**
	 * Loads the changes, and returns an instance of the class.
	 * @param array $changesData
	 * @return self
	 */
	public static function loadModifications( $changesData ) {
		return new self( $changesData );
	}

	/**
	 * Get all language keys with modifications under the group
	 * @return string[]
	 */
	public function getLanguages() {
		return array_keys( $this->changes );
	}

	/**
	 * Determines if the group has only a certain type of change under a language.
	 *
	 * @param string $language
	 * @param string $type
	 * @return bool
	 */
	public function hasOnly( $language, $type ) {
		$deletions = $this->getDeletions( $language );
		$additions = $this->getAdditions( $language );
		$renames = $this->getRenames( $language );
		$changes = $this->getChanges( $language );
		$hasOnlyAdditions = $hasOnlyRenames =
			$hasOnlyChanges = $hasOnlyDeletions = true;

		if ( $deletions ) {
			$hasOnlyAdditions = $hasOnlyRenames = $hasOnlyChanges = false;
		}

		if ( $renames ) {
			$hasOnlyDeletions = $hasOnlyAdditions = $hasOnlyChanges = false;
		}

		if ( $changes ) {
			$hasOnlyAdditions = $hasOnlyRenames = $hasOnlyDeletions = false;
		}

		if ( $additions ) {
			$hasOnlyDeletions = $hasOnlyRenames = $hasOnlyChanges = false;
		}

		if ( $type === self::DELETION ) {
			$response = $hasOnlyDeletions;
		} elseif ( $type === self::RENAME ) {
			$response = $hasOnlyRenames;
		} elseif ( $type === self::CHANGE ) {
			$response = $hasOnlyChanges;
		} elseif ( $type === self::ADDITION ) {
			$response = $hasOnlyAdditions;
		} else {
			throw new InvalidArgumentException( "Unknown $type passed." );
		}

		return $response;
	}

	/**
	 * Checks if the previous state of a renamed message matches a given value
	 * @param string $languageCode
	 * @param string $key
	 * @param string[] $types
	 * @return bool
	 */
	public function isPreviousState( $languageCode, $key, array $types ) {
		$msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );

		return isset( $msg['previous_state'] ) && in_array( $msg['previous_state'], $types );
	}

	/**
	 * Get matched rename message for a given key
	 * @param string $languageCode
	 * @param string $key
	 * @return array|null Matched message if found, else null
	 */
	public function getMatchedMessage( $languageCode, $key ) {
		$matchedKey = $this->getMatchedKey( $languageCode, $key );
		if ( $matchedKey ) {
			return $this->changes[ $languageCode ][ self::RENAME ][ $matchedKey ] ?? null;
		}

		return null;
	}

	/**
	 * Get matched rename key for a given key
	 * @param string $languageCode
	 * @param string $key
	 * @return string|null Matched key if found, else null
	 */
	public function getMatchedKey( $languageCode, $key ) {
		return $this->changes[ $languageCode ][ self::RENAME ][ $key ][ 'matched_to' ] ?? null;
	}

	/**
	 * Returns the calculated similarity for a rename
	 * @param string $languageCode
	 * @param string $key
	 * @return float|null
	 */
	public function getSimilarity( $languageCode, $key ) {
		$msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );

		return $msg[ 'similarity' ] ?? null;
	}

	/**
	 * Checks if a given key is equal to matched rename message
	 * @param string $languageCode
	 * @param string $key
	 * @return bool
	 */
	public function isEqual( $languageCode, $key ) {
		$msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
		return $msg && $this->areStringsEqual( $msg[ 'similarity' ] );
	}

	/**
	 * Checks if a given key is similar to matched rename message
	 *
	 * @param string $languageCode
	 * @param string $key
	 * @return bool
	 */
	public function isSimilar( $languageCode, $key ) {
		$msg = $this->findMessage( $languageCode, $key, [ self::RENAME ] );
		return $msg && $this->areStringsSimilar( $msg[ 'similarity' ] );
	}

	/**
	 * Checks if the similarity percent passed passes the min threshold
	 * @param float $similarity
	 * @return bool
	 */
	public function areStringsSimilar( $similarity ) {
		return $similarity >= self::SIMILARITY_THRESHOLD;
	}

	/**
	 * Checks if the similarity percent passed
	 * @param float $similarity
	 * @return bool
	 */
	public function areStringsEqual( $similarity ) {
		return $similarity === 1;
	}
}
