<?php

namespace Laminas\Session\SaveHandler;

use Laminas\Session\Exception\InvalidArgumentException;
use MongoDB\BSON\Binary;
use MongoDB\BSON\UTCDateTime;
use MongoDB\Client as MongoClient;
use MongoDB\Collection as MongoCollection;
use ReturnTypeWillChange;

use function array_replace;
use function floor;
use function ini_get;
use function microtime;
use function time;

/**
 * MongoDB session save handler
 *
 * @see ReturnTypeWillChange
 */
class MongoDB implements SaveHandlerInterface
{
    /**
     * MongoClient instance
     *
     * @var MongoClient
     */
    protected $mongoClient;

    /**
     * MongoCollection instance
     *
     * @var MongoCollection
     */
    protected $mongoCollection;

    /**
     * Session name
     *
     * @var string
     */
    protected $sessionName;

    /**
     * Session lifetime
     *
     * @var int
     */
    protected $lifetime;

    /**
     * MongoDB session save handler options
     *
     * @var MongoDBOptions
     */
    protected $options;

    /**
     * Constructor
     *
     * @param MongoClient $mongoClient
     * @throws InvalidArgumentException
     */
    public function __construct($mongoClient, MongoDBOptions $options)
    {
        if (null === ($database = $options->getDatabase())) {
            throw new InvalidArgumentException('The database option cannot be empty');
        }

        if (null === ($collection = $options->getCollection())) {
            throw new InvalidArgumentException('The collection option cannot be empty');
        }

        $this->mongoClient = $mongoClient;
        $this->options     = $options;
    }

    /**
     * Open session
     *
     * @param string $savePath
     * @param string $name
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function open($savePath, $name)
    {
        // Note: session save path is not used
        $this->sessionName = $name;
        $this->lifetime    = (int) ini_get('session.gc_maxlifetime');

        $this->mongoCollection = $this->mongoClient->selectCollection(
            $this->options->getDatabase(),
            $this->options->getCollection()
        );

        $this->mongoCollection->createIndex(
            [$this->options->getModifiedField() => 1],
            $this->options->useExpireAfterSecondsIndex() ? ['expireAfterSeconds' => $this->lifetime] : []
        );

        return true;
    }

    /**
     * Close session
     *
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function close()
    {
        return true;
    }

    /**
     * Read session data
     *
     * @param string $id
     * @return string
     */
    #[ReturnTypeWillChange]
    public function read($id)
    {
        $session = $this->mongoCollection->findOne([
            '_id'                          => $id,
            $this->options->getNameField() => $this->sessionName,
        ]);

        if (null !== $session) {
            // check if session has expired if index is not used
            if (! $this->options->useExpireAfterSecondsIndex()) {
                $timestamp  = $session[$this->options->getLifetimeField()];
                $timestamp += floor(((string) $session[$this->options->getModifiedField()]) / 1000);

                // session expired
                if ($timestamp <= time()) {
                    $this->destroy($id);
                    return '';
                }
            }
            return $session[$this->options->getDataField()]->getData();
        }

        return '';
    }

    /**
     * Write session data
     *
     * @param string $id
     * @param string $data
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function write($id, $data)
    {
        $saveOptions = array_replace(
            $this->options->getSaveOptions(),
            ['upsert' => true, 'multiple' => false]
        );

        $criteria = [
            '_id'                          => $id,
            $this->options->getNameField() => $this->sessionName,
        ];

        $newObj = [
            '$set' => [
                $this->options->getDataField()     => new Binary((string) $data, Binary::TYPE_GENERIC),
                $this->options->getLifetimeField() => $this->lifetime,
                $this->options->getModifiedField() => new UTCDateTime(floor(microtime(true) * 1000)),
            ],
        ];

        /* Note: a MongoCursorException will be thrown if a record with this ID
         * already exists with a different session name, since the upsert query
         * cannot insert a new document with the same ID and new session name.
         * This should only happen if ID's are not unique or if the session name
         * is altered mid-process.
         */
        $result = $this->mongoCollection->updateOne($criteria, $newObj, $saveOptions);

        return $result->isAcknowledged();
    }

    /**
     * Destroy session
     *
     * @param string $id
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function destroy($id)
    {
        $result = $this->mongoCollection->deleteOne(
            [
                '_id'                          => $id,
                $this->options->getNameField() => $this->sessionName,
            ],
            $this->options->getSaveOptions()
        );

        return $result->isAcknowledged();
    }

    /**
     * Garbage collection
     *
     * Note: MongoDB 2.2+ supports TTL collections, which may be used in place
     * of this method by indexing the "modified" field with an
     * "expireAfterSeconds" option. Regardless of whether TTL collections are
     * used, consider indexing this field to make the remove query more
     * efficient.
     *
     * @see http://docs.mongodb.org/manual/tutorial/expire-data/
     *
     * @param int $maxlifetime
     * @return bool
     */
    #[ReturnTypeWillChange]
    public function gc($maxlifetime)
    {
        /* Note: unlike DbTableGateway, we do not use the lifetime field in
         * each document. Doing so would require a $where query to work with the
         * computed value (modified + lifetime) and be very inefficient.
         */
        $microseconds = floor(microtime(true) * 1000) - $maxlifetime * 1000;

        $result = $this->mongoCollection->deleteMany(
            [
                $this->options->getModifiedField() => ['$lt' => new UTCDateTime($microseconds)],
            ],
            $this->options->getSaveOptions()
        );

        return $result->isAcknowledged();
    }
}
