<?php

// Copyright (C) 2024 Combodo SAS
//
//   This file is part of iTop.
//
//   iTop is free software; you can redistribute it and/or modify
//   it under the terms of the GNU Affero General Public License as published by
//   the Free Software Foundation, either version 3 of the License, or
//   (at your option) any later version.
//
//   iTop 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 Affero General Public License for more details.
//
//   You should have received a copy of the GNU Affero General Public License
//   along with iTop. If not, see <http://www.gnu.org/licenses/>

/**
 * Mechanism to obtain an exclusive lock while editing an object
 *
 * @package     iTopORM
 */

/**
 * Persistent storage (in the database) for remembering that an object is locked
 */
class iTopOwnershipToken extends DBObject
{
	public static function Init()
	{
		$aParams =
		[
			'category'            => '',
			'key_type'            => 'autoincrement',
			'name_attcode'        => ['obj_class', 'obj_key'],
			'state_attcode'       => '',
			'reconc_keys'         => [''],
			'db_table'            => 'priv_ownership_token',
			'db_key_field'        => 'id',
			'db_finalclass_field' => '',
		];
		MetaModel::Init_Params($aParams);
		MetaModel::Init_InheritAttributes();
		MetaModel::Init_AddAttribute(new AttributeDateTime("acquired", ["allowed_values" => null, "sql" => 'acquired', "default_value" => 'NOW()', "is_null_allowed" => false, "depends_on" => []]));
		MetaModel::Init_AddAttribute(new AttributeDateTime("last_seen", ["allowed_values" => null, "sql" => 'last_seen', "default_value" => 'NOW()', "is_null_allowed" => false, "depends_on" => []]));
		MetaModel::Init_AddAttribute(new AttributeString("obj_class", ["allowed_values" => null, "sql" => 'obj_class', "default_value" => '', "is_null_allowed" => false, "depends_on" => []]));
		MetaModel::Init_AddAttribute(new AttributeInteger("obj_key", ["allowed_values" => null, "sql" => 'obj_key', "default_value" => '', "is_null_allowed" => true, "depends_on" => []]));
		MetaModel::Init_AddAttribute(new AttributeString("token", ["allowed_values" => null, "sql" => 'token', "default_value" => '', "is_null_allowed" => true, "depends_on" => []]));
		MetaModel::Init_AddAttribute(new AttributeExternalKey("user_id", ["targetclass" => "User", "jointype" => '', "allowed_values" => null, "sql" => "user_id", "is_null_allowed" => true, "on_target_delete" => DEL_SILENT, "depends_on" => []]));

		MetaModel::Init_SetZListItems('details', ['obj_class', 'obj_key', 'last_seen', 'token']);
		MetaModel::Init_SetZListItems('standard_search', ['obj_class', 'obj_key', 'last_seen', 'token']);
		MetaModel::Init_SetZListItems('list', ['obj_class', 'obj_key', 'last_seen', 'token']);

	}
}

/**
 * Utility class to acquire/extend/release/kill an exclusive lock on a given persistent object,
 * for example to prevent concurrent edition of the same object.
 * Each lock has an expiration delay of 120 seconds (tunable via the configuration parameter 'concurrent_lock_expiration_delay')
 * A watchdog (called twice during this delay) is in charge of keeping the lock "alive" while an object is being edited.
 */
class iTopOwnershipLock
{
	protected $sObjClass;
	protected $iObjKey;
	protected $oToken;

	/**
	 * Acquires an exclusive lock on the specified DBObject. Once acquired, the lock is identified
	 * by a unique "token" string.
	 * @param string $sObjClass The class of the object for which to acquire the lock
	 * @param integer $iObjKey The identifier of the object for which to acquire the lock
	 * @return multitype:boolean iTopOwnershipLock Ambigous <boolean, string, DBObjectSet>
	 */
	public static function AcquireLock($sObjClass, $iObjKey)
	{
		$oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey);

		$oMutex->Lock();
		$oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey);
		$token = $oOwnershipLock->Acquire();
		$oMutex->Unlock();

		return ['success' => $token !== false, 'token' => $token, 'lock' => $oOwnershipLock, 'acquired' => $oOwnershipLock->oToken->Get('acquired')];
	}

	/**
	 * Extends the ownership lock or acquires it if none exists
	 * Returns a hash array with 3 elements:
	 * 'status': either true or false, tells if the lock is still owned
	 * 'owner': is status is false, the User object currently owning the lock
	 * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration has been extended) or 'acquired' (there was no valid lock for this object and a new one was created)
	 * @param string $sToken
	 * @return multitype:boolean string User
	 */
	public static function ExtendLock($sObjClass, $iObjKey, $sToken)
	{
		$oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey);

		$oMutex->Lock();
		$oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey);
		$aResult = $oOwnershipLock->Extend($sToken);
		$oMutex->Unlock();

		return $aResult;
	}

	/**
	 * Releases the given lock for the specified object
	 *
	 * @param string $sObjClass The class of the object
	 * @param int $iObjKey The identifier of the object
	 * @param string $sToken The string identifying the lock
	 * @return boolean
	 */
	public static function ReleaseLock($sObjClass, $iObjKey, $sToken)
	{
		$oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey);

		$oMutex->Lock();
		$oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey);
		$bResult = $oOwnershipLock->Release($sToken);
		self::DeleteExpiredLocks(); // Cleanup orphan locks
		$oMutex->Unlock();

		return $bResult;
	}

	/**
	 * Kills the lock for the specified object
	 *
	 * @param string $sObjClass The class of the object
	 * @param int $iObjKey The identifier of the object
	 * @return boolean
	 */
	public static function KillLock($sObjClass, $iObjKey)
	{
		$oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey);

		$oMutex->Lock();
		$sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key";
		$oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['obj_class' => $sObjClass, 'obj_key' => $iObjKey]));
		while ($oLock = $oSet->Fetch()) {
			$oLock->DBDelete();
		}
		$oMutex->Unlock();
	}

	/**
	 * Checks if an exclusive lock exists on the specified DBObject.
	 * @param string $sObjClass The class of the object for which to acquire the lock
	 * @param integer $iObjKey The identifier of the object for which to acquire the lock
	 * @return multitype:boolean iTopOwnershipLock Ambigous <boolean, string, DBObjectSet>
	 */
	public static function IsLocked($sObjClass, $iObjKey)
	{
		$bLocked = false;
		$oMutex = new iTopMutex('lock_'.$sObjClass.'::'.$iObjKey);

		$oMutex->Lock();
		$oOwnershipLock = new iTopOwnershipLock($sObjClass, $iObjKey);
		if ($oOwnershipLock->IsOwned()) {
			$bLocked = true;
		}
		$oMutex->Unlock();

		return ['locked' => $bLocked, 'owner' => $oOwnershipLock->GetOwner()];
	}

	/**
	 * Get the current owner of the lock
	 * @return User
	 */
	public function GetOwner()
	{
		if ($this->IsTokenValid()) {
			return MetaModel::GetObject('User', $this->oToken->Get('user_id'), false, true);
		}
		return null;
	}

	/**
	 * The constructor is protected. Use the static methods AcquireLock / ExtendLock / ReleaseLock / KillLock
	 * which are protected against concurrent access by a Mutex.
	 * @param string $sObjClass The class of the object for which to create a lock
	 * @param integer $iObjKey The identifier of the object for which to create a lock
	 */
	protected function __construct($sObjClass, $iObjKey)
	{
		$sOQL = "SELECT iTopOwnershipToken WHERE obj_class = :obj_class AND obj_key = :obj_key";
		$oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['obj_class' => $sObjClass, 'obj_key' => $iObjKey]));
		$this->oToken = $oSet->Fetch();
		$this->sObjClass = $sObjClass;
		$this->iObjKey = $iObjKey;
		// IssueLog::Info("iTopOwnershipLock::__construct($sObjClass, $iObjKey) oToken::".($this->oToken ? $this->oToken->GetKey() : 'null'));
	}

	protected function IsOwned()
	{
		return $this->IsTokenValid();
	}

	protected function Acquire($sToken = null)
	{
		if ($this->IsTokenValid()) {
			// IssueLog::Info("Acquire($sToken) returns false");
			return false;
		} else {
			$sToken = $this->TakeOwnership($sToken);
			// IssueLog::Info("Acquire($sToken) returns $sToken");
			return $sToken;
		}
	}

	/**
	 * Extends the ownership lock or acquires it if none exists
	 * Returns a hash array with 3 elements:
	 * 'status': either true or false, tells if the lock is still owned
	 * 'owner': is status is false, the User object currently owning the lock
	 * 'operation': whether the lock was 'renewed' (i.e. the lock was valid, its duration was extended) or 'expired' (there was no valid lock for this object) or 'lost' (someone else grabbed it)
	 * 'acquired': date at which the lock was initially acquired
	 * @param string $sToken
	 * @return multitype:boolean string User
	 */
	protected function Extend($sToken)
	{
		$aResult = ['status' => true, 'owner' => '', 'operation' => 'renewed'];

		if ($this->IsTokenValid()) {
			if ($sToken === $this->oToken->Get('token')) {
				$this->oToken->Set('last_seen', date(AttributeDateTime::GetSQLFormat()));
				$this->oToken->DBUpdate();
				$aResult['acquired'] = $this->oToken->Get('acquired');
			} else {
				// IssueLog::Info("Extend($sToken) returns false");
				$aResult['status'] = false;
				$aResult['operation'] = 'lost';
				$aResult['owner'] = $this->GetOwner();
				$aResult['acquired'] = $this->oToken->Get('acquired');
			}
		} else {
			$aResult['status'] = false;
			$aResult['operation'] = 'expired';
		}
		// IssueLog::Info("Extend($sToken) returns true");
		return $aResult;
	}

	protected function HasOwnership($sToken)
	{
		$bRet = false;
		if ($this->IsTokenValid()) {
			if ($sToken === $this->oToken->Get('token')) {
				$bRet = true;
			}
		}
		// IssueLog::Info("HasOwnership($sToken) return $bRet");
		return $bRet;
	}

	protected function Release($sToken)
	{
		$bRet = false;
		// IssueLog::Info("Release... begin [$sToken]");
		if (($this->oToken) && ($sToken === $this->oToken->Get('token'))) {
			// IssueLog::Info("oToken::".$this->oToken->GetKey().' ('.$sToken.') to be deleted');
			$this->oToken->DBDelete();
			// IssueLog::Info("oToken deleted");
			$this->oToken = null;
			$bRet = true;
		} elseif ($this->oToken == null) {
			// IssueLog::Info("Release FAILED oToken == null !!!");
		} else {
			// IssueLog::Info("Release FAILED inconsistent tokens: sToken=\"".$sToken.'", oToken->Get(\'token\')="'.$this->oToken->Get('token').'"');
		}
		// IssueLog::Info("Release... end");
		return $bRet;
	}

	protected function IsTokenValid()
	{
		$bRet = false;
		if ($this->oToken != null) {
			$sToken = $this->oToken->Get('token');
			$sDate = $this->oToken->Get('last_seen');
			if (($sDate != '') && ($sToken != '')) {
				$oLastSeenTime = new DateTime($sDate);
				$iNow = date('U');
				if (($iNow - $oLastSeenTime->format('U')) < MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay')) {
					$bRet = true;
				}
			}
		}
		return $bRet;
	}

	protected function TakeOwnership($sToken = null)
	{
		if ($this->oToken == null) {
			$this->oToken = new iTopOwnershipToken();
			$this->oToken->Set('obj_class', $this->sObjClass);
			$this->oToken->Set('obj_key', $this->iObjKey);
		}
		$this->oToken->Set('acquired', date(AttributeDateTime::GetSQLFormat()));
		$this->oToken->Set('user_id', UserRights::GetUserId());
		$this->oToken->Set('last_seen', date(AttributeDateTime::GetSQLFormat()));
		if ($sToken === null) {
			$sToken = sprintf('%X', microtime(true));
		}
		$this->oToken->Set('token', $sToken);
		$this->oToken->DBWrite();
		return $this->oToken->Get('token');
	}

	protected static function DeleteExpiredLocks()
	{
		$sOQL = "SELECT iTopOwnershipToken WHERE last_seen < :last_seen_limit";
		$oSet = new DBObjectSet(DBObjectSearch::FromOQL($sOQL, ['last_seen_limit' => date(AttributeDateTime::GetSQLFormat(), time() - MetaModel::GetConfig()->Get('concurrent_lock_expiration_delay'))]));
		while ($oToken = $oSet->Fetch()) {
			$oToken->DBDelete();
		}

	}
}
