<?php

/**
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU General Public License
 * as published by the Free Software Foundation; under version 2
 * of the License (non-upgradable).
 *
 * This program 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 *
 * Copyright (c) 2020 (original work) Open Assessment Technologies SA (under the project TAO-PRODUCT);
 *
 */

declare(strict_types=1);

namespace oat\taoTaskQueue\model\QueueBroker;

use common_persistence_Manager;
use common_persistence_SqlPersistence;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Exception;
use InvalidArgumentException;
use oat\generis\Helper\UuidPrimaryKeyTrait;
use oat\tao\model\taskQueue\Queue\Broker\AbstractQueueBroker;
use oat\tao\model\taskQueue\Task\TaskInterface;
use oat\taoTaskQueue\model\QueueBroker\storage\NewSqlSchema;
use PDO;
use Throwable;

/**
 * Storing messages/tasks in newSQl DB.
 */
class NewSqlQueueBroker extends AbstractQueueBroker
{
    public const ID ='newsql';

    use UuidPrimaryKeyTrait;

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

    /** @var common_persistence_SqlPersistence */
    protected $persistence;

    public function __construct(string $persistenceId, int $receiveTasks = 1)
    {
        parent::__construct($receiveTasks);

        if (empty($persistenceId)) {
            throw new InvalidArgumentException("Persistence id needs to be set for " . __CLASS__);
        }

        $this->persistenceId = $persistenceId;
    }

    public function __toPhpCode()
    {
        return 'new ' . get_called_class() . '('
            . \common_Utils::toHumanReadablePhpString($this->persistenceId)
            . ', '
            . \common_Utils::toHumanReadablePhpString($this->getNumberOfTasksToReceive())
            . ')';
    }

    /**
     * Note: this method can be run multiple times because only the migrate queries
     * (result of getMigrateSchemaSql) will be run.
     *
     * @inheritdoc
     */
    public function createQueue(): void
    {
        $persistence = $this->getPersistence();
        /** @var AbstractSchemaManager $schemaManager */
        $schemaManager = $persistence->getDriver()->getSchemaManager();
        $schema = $schemaManager->createSchema();
        $fromSchema = clone $schema;
        try {
            $schema->dropTable($this->getTableName());
        } catch (Throwable $exception) {
            $this->logDebug('Schema of ' . $this->getTableName() . ' table already up to date.');
        }
        // Create the table
        $schema = $this->getSchemaProvider()
            ->setQueueName($this->getQueueName())
            ->getSchema($schema, $this->getTableName());
        $queries = $persistence->getPlatForm()->getMigrateSchemaSql($fromSchema, $schema);
        foreach ($queries as $query) {
            $persistence->exec($query);
        }
    }

    /**
     * Insert a new task into the queue table.
     */
    public function push(TaskInterface $task): bool
    {
        return (bool)$this->getPersistence()->insert($this->getTableName(), [
            'id' => $this->getUniquePrimaryKey(),
            'message' => $this->serializeTask($task),
            'created_at' => $this->getPersistence()->getPlatForm()->getNowExpression(),
            'visible' => true,
        ]);
    }

    public function delete(TaskInterface $task): void
    {
        $this->doDelete($task->getMetadata('NewSqlMessageId'), [
            'InternalMessageId' => $task->getId(),
            'NewSqlMessageId' => $task->getMetadata('NewSqlMessageId')
        ]);
    }

    public function count(): int
    {
        try {
            return (int)$this->getQueryBuilder()
                ->select('COUNT(id)')
                ->from($this->getTableName())
                ->andWhere('visible = :visible')
                ->setParameter('visible', true, ParameterType::BOOLEAN)
                ->execute()
                ->fetchColumn();
        } catch (Exception $e) {
            $this->logError('Counting tasks failed with MSG: ' . $e->getMessage());
        }

        return 0;
    }


    /**
     * @inheritDoc
     */
    protected function doPop(): void
    {
        $this->getPersistence()->getPlatform()->beginTransaction();

        $logContext = $this->getLogContext();

        try {
            $dbResult = $this->fetchVisibleMessages();

            if ($dbResult) {
                // set the received messages to invisible for other workers
                $this->changeMessagesVisibility($dbResult);

                $this->processMessages($dbResult, $logContext);
            } else {
                $this->logDebug('No task in the queue.', $logContext);
            }

            $this->getPersistence()->getPlatform()->commit();
        } catch (Exception $e) {
            $this->getPersistence()->getPlatform()->rollBack();
            $this->logError('Popping tasks failed with MSG: ' . $e->getMessage(), $logContext);
        }
    }

    protected function doDelete($id, array $logContext = []): void
    {
        try {
            $this->getQueryBuilder()
                ->delete($this->getTableName())
                ->where('id = :id')
                ->andWhere('visible = :visible')
                ->setParameter('id', $id)
                ->setParameter('visible', false, ParameterType::BOOLEAN)
                ->execute();
        } catch (Exception $e) {
            $this->logError('Deleting task failed with MSG: ' . $e->getMessage(), $logContext);
        }
    }

    private function getSchemaProvider(): NewSqlSchema
    {
        return $this->getServiceLocator()->get(NewSqlSchema::class);
    }

    private function getQueryBuilder(): QueryBuilder
    {
        return $this->getPersistence()->getPlatform()->getQueryBuilder();
    }

    private function getPersistence(): ?common_persistence_SqlPersistence
    {
        if (is_null($this->persistence)) {
            $this->persistence = $this->getServiceLocator()
                ->get(common_persistence_Manager::SERVICE_ID)
                ->getPersistenceById($this->persistenceId);
        }

        return $this->persistence;
    }

    private function getTableName(): string
    {
        return strtolower($this->getQueueNameWithPrefix());
    }

    private function changeMessagesVisibility(array $dbResult): void
    {
        $qb = $this->getQueryBuilder()
            ->update($this->getTableName())
            ->set('visible', ':visible')
            ->where('id IN (:ids)')
            ->setParameter('visible', false, ParameterType::BOOLEAN)
            ->setParameter('ids', array_column($dbResult, 'id'), Connection::PARAM_STR_ARRAY);

        $qb->execute();
    }

    private function processMessages(array $dbResult, array $logContext): void
    {
        foreach ($dbResult as $row) {
            if ($task = $this->unserializeTask($row['message'], $row['id'], $logContext)) {
                $task->setMetadata('NewSqlMessageId', $row['id']);
                $this->pushPreFetchedMessage($task);
            }
        }
    }

    private function fetchVisibleMessages(): array
    {
        $qb = $this->getQueryBuilder()
            ->select('id, message')
            ->from($this->getTableName())
            ->where('visible = :visible')
            ->orderBy('created_at')
            ->setMaxResults($this->getNumberOfTasksToReceive());

        /**
         * SELECT ... FOR UPDATE is used for locking
         */
        $sql = $qb->getSQL() . ' ' . $this->getPersistence()->getPlatForm()->getWriteLockSQL();

        return $this->getPersistence()->query($sql, ['visible' => true])->fetchAll(PDO::FETCH_ASSOC);
    }

    private function getLogContext(): array
    {
        return [
            'Queue' => $this->getQueueNameWithPrefix()
        ];
    }
}