<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

namespace Shopware\Plugins\ViisonCommon\Components\GlobalLocking;

use Doctrine\DBAL\Connection as DBALConnection;
use Doctrine\DBAL\DBALException;
use Exception;
use InvalidArgumentException;
use LogicException;
use Psr\Log\LoggerInterface;
use Shopware\Plugins\ViisonCommon\Classes\TryWithFinally;

/**
 * This service provides two public methods 'acquireLock' and 'releaseLock' that should be used together to first
 * acquire a global lock (global meaning across all PHP processes communicating with the same database) and then release
 * it when it is not needed anymore. Since we must support MySQL 5.5, only one lock can be acquired and held at a time.
 *
 * @deprecated this service has been moved to ViisonPickwareERP, which means that it can only be used by
 *             ViisonPickwareERP and plugins depending on ViisonPickwareERP.
 */
class GlobalLockingService
{
    /**
     * The time after which a locking attempt in the database times out by default (in seconds).
     */
    const DEFAULT_LOCK_ACQUISITION_TIMEOUT = 15;

    /**
     * We store the mutex of the acquired lock in a static property to make sure that its not possible to use this
     * service to overwrite an acquired lock in any way.
     *
     * @var ReentrantLockMutex|null
     */
    private static $acquiredLockMutex = null;

    /**
     * Use the getter to access the ID, since it is loaded lazily!
     *
     * @var string|null
     */
    private $databaseConnectionId = null;

    /**
     * @var DBALConnection
     */
    private $dbalConnection;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @var bool
     */
    private $skipLockStillHeldValidation;

    /**
     * @param DBALConnection $dbalConnection
     * @param LoggerInterface $logger
     * @param bool $skipLockStillHeldValidation
     */
    public function __construct(DBALConnection $dbalConnection, LoggerInterface $logger, $skipLockStillHeldValidation)
    {
        $this->dbalConnection = $dbalConnection;
        $this->logger = $logger;
        $this->skipLockStillHeldValidation = $skipLockStillHeldValidation;
    }

    /**
     * Tries to acquire a lock for the given $lockId. If a lock with the same $lockId is already held by this service,
     * only its mutex is updated accordingly. Use the optional parameter $lockingOperationTimeout to control how long
     * this process should wait on the lock, if it is currently held by a different process.
     *
     * @param string $lockId
     * @param int $lockingOperationTimeout (optional)
     * @throws InvalidArgumentException If the provided lock ID exceeds a length of 64 characters.
     * @throws LogicException If a lock with a different ID is currently held by this service.
     * @throws GlobalLockingException If the lock could not be acquired.
     */
    public function acquireLock($lockId, $lockingOperationTimeout = self::DEFAULT_LOCK_ACQUISITION_TIMEOUT)
    {
        if (mb_strlen($lockId) > 64) {
            throw new InvalidArgumentException(
                sprintf('The provided lock ID "%s" exceeds the max. length of 64 characters.', $lockId)
            );
        }

        // Make sure that only one lock is held at a time
        if ($this->hasAcquiredValidLock()) {
            if (self::$acquiredLockMutex->getLockId() !== $lockId) {
                throw new LogicException(sprintf(
                    'Cannot acquire lock with ID "%s", because a lock with a different ID "%s" has already been acquired.',
                    $lockId,
                    self::$acquiredLockMutex->getLockId()
                ));
            }

            // Just update the mutex
            self::$acquiredLockMutex->acquire();

            return;
        }

        try {
            // Acquire a new database lock for the given ID
            $result = $this->dbalConnection->fetchArray(
                'SELECT GET_LOCK(:lockId, :timeout)',
                [
                    'lockId' => $lockId,
                    'timeout' => $lockingOperationTimeout,
                ]
            );
        } catch (DBALException $e) {
            throw GlobalLockingException::dbalException($lockId, $e);
        }
        if ($result[0] === '0') {
            throw GlobalLockingException::lockingTimedOut($lockId);
        } elseif ($result[0] === null) {
            throw GlobalLockingException::getLockReturnedNull($lockId);
        }

        // Save the lock in a new mutex
        self::$acquiredLockMutex = new ReentrantLockMutex($lockId);
    }

    /**
     * Releases the lock with the given $lockId, if it is held by this service instance. That is, the lock is only
     * released in the database, if the respective mutex is decreased/released for the final time.
     *
     * @param string $lockId
     * @throws LogicException If a non-existent or wrong lock should be released.
     */
    public function releaseLock($lockId)
    {
        if (!self::$acquiredLockMutex || self::$acquiredLockMutex->getLockId() !== $lockId) {
            // Someone tries to release a lock that has never been acquired or released in the meantime
            throw new LogicException(sprintf(
                'Cannot release lock with ID "%s", because it is currently not held by the locking service.',
                $lockId
            ));
        }

        // Update the lock mutex
        self::$acquiredLockMutex->free();
        if (!self::$acquiredLockMutex->canReleaseLock()) {
            return;
        }

        // Final release call, hence release the lock in the database. We don't check the result of the RELEASE_LOCK()
        // operation, because other plugins/classes might have acquired a different lock in the meantime, which would
        // overwrite our own lock in MySQL prior 5.7.5.
        try {
            TryWithFinally::tryWithFinally(
                function () {
                    $this->dbalConnection->fetchArray(
                        'SELECT RELEASE_LOCK(:lockId)',
                        [
                            'lockId' => self::$acquiredLockMutex->getLockId(),
                        ]
                    );
                },
                function () {
                    self::$acquiredLockMutex = null;
                }
            );
        } catch (Exception $e) {
            $this->logger->error(
                'ViisonCommon: Failed to release database lock.',
                [
                    'exception' => $e,
                ]
            );
        }
    }

    /**
     * @return boolean true, if this service is currently holding a lock that is still valid in the database. false otherwise.
     */
    private function hasAcquiredValidLock()
    {
        if (!self::$acquiredLockMutex) {
            return false;
        }

        // The validation below is required for database servers which will silently release any named locks already
        // acquired by the active connection as soon as another named lock is acquired. Connections can hold multiple
        // named locks as of MySQL 5.7 and MariaDB 10.0.2, which makes the check below redundant. Since the check itself
        // can cause issues on some cluster setups (e.g. when ProxySQL is used), it can be disabled using an
        // undocumented kernel parameter.
        if ($this->skipLockStillHeldValidation) {
            return true;
        }

        // Check whether the lock is still valid in the database
        $result = $this->dbalConnection->fetchArray(
            'SELECT IS_USED_LOCK(:lockId)',
            [
                'lockId' => self::$acquiredLockMutex->getLockId(),
            ]
        );
        $connectionId = $result[0];

        // Make sure the database connection of this process holds the lock
        return $connectionId !== null && intval($connectionId) === $this->getDatabaseConnectionId();
    }

    /**
     * @return int
     */
    private function getDatabaseConnectionId()
    {
        if (!$this->databaseConnectionId) {
            $connectionId = $this->dbalConnection->fetchArray('SELECT CONNECTION_ID()');
            $this->databaseConnectionId = intval($connectionId[0]);
        }

        return $this->databaseConnectionId;
    }
}
