<?php
namespace App\Cron\Mutex;

/**
 * Use flock() calls to put a lock on a file, and also enfoce that lock checking the lock timestamp
 * Basically this is an clone of \App\Cron\Mutex\FLock, with lots of extra checks and protections
 * 	- it writes a random hash to the lockFile, and checks against it when the lock was opened by this process, avoiding another process that might rewrite the file content concurrently (before actually locking the file)
 * 	- it uses fstat() in order to determine if the lockfile was deleted on disk, while the file it's still being opened in the current process
 * 	- leaves handles open, to avoid reporting the currenlty-locked file as being deleted then re-locked by another process (in this case, the file is trylly still locked, but not by the current process)
 * 	- it separates the isLocked() method in 2, treating currently-locked files completly different than files locked by other processes
 * @author Lucian Sirbu
 */
class FLockWithHash implements AbstractLock
{
	/**
	 * Lock file resource
	 * @var resource
	 */
	private $handle = null;

	/**
	 * Lock file path
	 * @var string
	 */
	private $file = null;

	/**
	 * Lock file path
	 * @var string
	 */
	private $lockhash = null;

	private $isLockedReasons = [];

	public function __construct($id)
	{
		$CFG = \Engine\Loader_Cfg::getInstance()->getGlobal();
		$this->setFile(sprintf("%s%s%s.lock", APP_REAL_PATH, $CFG['paths']['temporary'], $id));
	}

	/**
	 * Set the file to be locked/unlocked
	 * @return \App\Cron\Mutex\FLockWithHash
	 */
	protected function setFile($file)
	{
		$this->file = $file;
		return $this;
	}

	/**
	 * Lock and block waiting
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::lock()
	 */
	public function lock()
	{
		if (!file_exists($this->file)) {
			file_put_contents($this->file, '');
			chmod($this->file, 0777);
		}

		// open "create", without truncating the file ( do NOT use 'wb' )
		$this->handle = fopen($this->file, 'cb+');
		if (!$this->handle) {
			throw new \RuntimeException(sprintf("Cannot open %s, for locking", $this->file));
		}

		$locked = flock($this->handle, LOCK_EX);	// shared lock - read-only
		if (!$locked) {
			throw new \RuntimeException(sprintf("Cannot lock %s", $this->file));
		}

		$this->lockhash = sprintf("%s:% 9s", \Engine\RandomGenerator::randomString(16), getmypid());
		fwrite($this->handle, $this->lockhash);

		return $this;
	}

	/**
	 * Unlock, if previously locked
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::unlock()
	 */
	public function unlock()
	{
		if ($this->handle) {
			flock($this->handle, LOCK_UN);	// the lock will automatically get released by the OS anyway, according to http://php.net/manual/en/function.flock.php#100864
			fclose($this->handle);
			$this->handle = null;
		}

		$this->lockhash = null;

		return $this;
	}

	/**
	 * Check if a lock is in effect
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::isLocked()
	 */
	public function isLocked()
	{
		// reset internal lock data
		$this->isLockedReasons = [];

		clearstatcache(false, $this->file);

		if (!file_exists($this->file)) {
			// reset internal state, file isn't on disk. A lock might have been stolen from us
			$this->isLockedReasons = [
				'fileExists' => false,
			];
			return false;
		}

		// if we're the ones that aquired the lock, check if the aquire timestamp is the one we expect, if not, it means, the lock was somehow stolen from us
		if ($this->handle) {
			fseek($this->handle, 0, SEEK_SET);
			$lockhash = fread($this->handle, 256);

			$fstat = fstat($this->handle);

			/*
			 * consider the file as locked if:
			 * 	- internally the file is open
			 * 	- the file content is identical.
			 * 		This check could return a false-positive is the underlying lockfile is deleted, but because the file is still open by this process, it will be kept in memory, and fread() will still return valid data
			 * 	- fstat->nlink is non-zero (usually it's 1) (reference count of hard links). Seems that each file in a folder has at least one hard link pointing to it
			 * 		This exists in order to counteract the file-content issue described above.
			 *
			 * If you add more checks here, you should also update the class comments
			 * */
			$locked = ($this->handle!==null && $lockhash===$this->lockhash && $fstat['nlink']!==0);

			if ($locked) {
				return true;
			}
			else {
				// save the internal checked data, it's reported in the main cronjob in order to determine why a lock got "stolen" afterwards
				$this->isLockedReasons = [
					'this->handle' => 	($this->handle!==null),
					'this->lockhash' => $this->lockhash,
					'lockhash' => 		$lockhash,
					'fstat->nlink' => 	$fstat['nlink'],
				];

				// we already lost the lock, return false. Do not release internal state, as the lock might be already held by someone else
				return false;
			}
		}

		//////////////////////////////////////////////////////////////////////////////////////////
		///// we're not the ones that aquired the lock, open the file and check if its locked ////
		//////////////////////////////////////////////////////////////////////////////////////////

		$fileIsLocked = false;

		// open "read-only"
		$handle = fopen($this->file, 'rb');
		if (!$handle) {
			$this->isLockedReasons = [
				'fopen:failed' => 	true,
			];

			// couldn't open the file, it's probably locked by someone else
			return true;
		}

		// inspired from http://php.net/manual/en/function.flock.php#45464
		$wouldBlock = null;
		$lockSucceeded = flock($handle, LOCK_EX | LOCK_NB, $wouldBlock);

		if (!$lockSucceeded || $wouldBlock) {
			$fileIsLocked = true;
			flock($handle, LOCK_UN);
		}

		$this->isLockedReasons = [
			'flock, interpreted' => !$fileIsLocked,
			'lockhash' => 			fread($handle, 256),
			'lockSucceeded' =>		$lockSucceeded,
			'wouldBlock' => 		$wouldBlock,
		];

		fclose($handle);

		return $fileIsLocked;
	}

	/**
	 * Check if a lock is in effect.
	 * When doing checks in parallel (FluX-es/Cronjobs) then one of the callers might lock the other one and thus cause a false-positive
	 * In order to avoid this, we keep re-checking the lock until it's gone or the timeout passes
	 * @return bool
	 */
	public function isLockedWithTimeout($timeoutMin=0.0, $timeoutMax=0.5, $loopsMin=1, $loopsMax=PHP_INT_MAX)
	{
		$keepWaiting = true;
		$loopStart = microtime(true);
		$locked = true;
		$loops = 0;
		do {
			if ($keepWaiting && !$this->isLocked()) {
				$locked = false;
			}

			// internal timeout expired
			if ($timeoutMax!==null && $keepWaiting && (microtime(true) - $loopStart)>=$timeoutMax) {
				$keepWaiting = false;
			}

			// internal loop limit expired
			if ($loopsMax!==null && $keepWaiting && $loops>=$loopsMax) {
				$keepWaiting = false;
			}

			// minimum limits checks
			if ($loopsMin!==null && !$keepWaiting && $loops<$loopsMin) {
				$keepWaiting = true;
			}
			if ($timeoutMin!==null && !$keepWaiting && (microtime(true) - $loopStart)<$timeoutMin) {
				$keepWaiting = true;
			}

			if ($keepWaiting && $locked) {
				usleep(1);
			}
			$loops++;
		} while ($keepWaiting && $locked);

		return $locked;
	}

	/**
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::rawGetIsLockedReasons()
	 */
	public function rawGetIsLockedReasons()
	{
		return $this->isLockedReasons;
	}

	/**
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::wasAquired()
	 */
	public function wasAquired()
	{
		return (bool)$this->handle;
	}

	/**
	 * {@inheritDoc}
	 * @see \App\Cron\Mutex\AbstractLock::lockWithTimeout()
	 */
	public function lockWithTimeout($timeout)
	{
		$keepWaiting = true;
		$loopStart = microtime(true);
		$locked = false;
		do {
			// the lock is unlocked, lock now
			if ($keepWaiting && !$this->isLocked()) {
				$keepWaiting = false;
				$locked = true;
				$this->lock();
			}

			// internal timeout expired
			if ($keepWaiting && (microtime(true) - $loopStart)>=$timeout) {
				$keepWaiting = false;
			}

			if ($keepWaiting) {
				usleep(1);
			}
		} while ($keepWaiting);

		return $locked;
	}

	/**
	 * Physically removes the lockfile
	 * !!! USE CAREFULLY !!!
	 * @return bool
	 */
	public function removeLock()
	{
		unlink($this->file);
		return $this;
	}

}
