<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\DocStoreBundle\Entity;

use Chill\DocStoreBundle\Validator\Constraints\AsyncFileExists;
use ChampsLibres\WopiLib\Contract\Entity\Document;
use Chill\DocGeneratorBundle\Entity\DocGeneratorTemplate;
use Chill\MainBundle\Doctrine\Model\TrackCreationInterface;
use Chill\MainBundle\Doctrine\Model\TrackCreationTrait;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Order;
use Doctrine\Common\Collections\ReadableCollection;
use Doctrine\Common\Collections\Selectable;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
use Random\RandomException;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Represent a document stored in an object store.
 *
 * StoredObjects 's content should be read and written using the @see{StoredObjectManagerInterface}.
 *
 * The property `$deleteAt` allow a deletion of the document after the given date. But this property should
 * be set before the document is actually written by the StoredObjectManager.
 *
 * Each version is stored within a @see{StoredObjectVersion}, associated with this current's object. The creation
 * of each new version should be done using the method @see{self::registerVersion}.
 */
#[ORM\Entity]
#[ORM\Table('stored_object', schema: 'chill_doc')]
class StoredObject implements Document, TrackCreationInterface
{
    use TrackCreationTrait;
    final public const STATUS_EMPTY = 'empty';
    final public const STATUS_READY = 'ready';
    final public const STATUS_PENDING = 'pending';
    final public const STATUS_FAILURE = 'failure';

    #[Serializer\Groups(['write'])]
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, name: 'datas')]
    private array $datas = [];

    /**
     * the prefix of each version.
     */
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
    private string $prefix = '';

    #[Serializer\Groups(['write'])]
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER)]
    private ?int $id = null;

    #[Serializer\Groups(['write'])]
    #[ORM\Column(name: 'title', type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => ''])]
    private string $title = '';

    #[Serializer\Groups(['write'])]
    #[ORM\Column(type: 'uuid', unique: true)]
    private UuidInterface $uuid;

    #[ORM\ManyToOne(targetEntity: DocGeneratorTemplate::class)]
    private ?DocGeneratorTemplate $template = null;

    /**
     * Store the number of times a generation has been tryied for this StoredObject.
     *
     * This is a workaround, as generation consume lot of memory, and out-of-memory errors
     * are not handled by messenger.
     */
    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, options: ['default' => 0])]
    private int $generationTrialsCounter = 0;

    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])]
    private ?\DateTimeImmutable $deleteAt = null;

    #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])]
    private string $generationErrors = '';

    /**
     * @var Collection<int, StoredObjectVersion>&Selectable<int, StoredObjectVersion>
     */
    #[ORM\OneToMany(mappedBy: 'storedObject', targetEntity: StoredObjectVersion::class, cascade: ['persist'], orphanRemoval: true, fetch: 'EAGER')]
    private Collection&Selectable $versions;

    /**
     * @param StoredObject::STATUS_* $status
     */
    public function __construct(
        #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, options: ['default' => 'ready'])]
        private string $status = 'empty',
    ) {
        $this->uuid = Uuid::uuid4();
        $this->versions = new ArrayCollection();
        $this->prefix = self::generatePrefix();
    }

    public function addGenerationTrial(): self
    {
        ++$this->generationTrialsCounter;

        return $this;
    }

    /**
     * @deprecated
     */
    #[Serializer\Groups(['write'])]
    public function getCreationDate(): \DateTime
    {
        if (null === $this->createdAt) {
            // this scenario will quite never happens
            return new \DateTime('now');
        }

        return \DateTime::createFromImmutable($this->createdAt);
    }

    #[AsyncFileExists(message: 'The file is not stored properly')]
    #[Assert\NotNull(message: 'The store object version must be present')]
    public function getCurrentVersion(): ?StoredObjectVersion
    {
        $maxVersion = null;

        foreach ($this->versions as $v) {
            if ($v->getVersion() > ($maxVersion?->getVersion() ?? -1)) {
                $maxVersion = $v;
            }
        }

        return $maxVersion;
    }

    public function getDatas(): array
    {
        return $this->datas;
    }

    public function getPrefix(): string
    {
        return $this->prefix;
    }

    public function getFilename(): string
    {
        return $this->getCurrentVersion()?->getFilename() ?? '';
    }

    public function getGenerationTrialsCounter(): int
    {
        return $this->generationTrialsCounter;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return list<int>
     */
    public function getIv(): array
    {
        return $this->getCurrentVersion()?->getIv() ?? [];
    }

    public function getKeyInfos(): array
    {
        return $this->getCurrentVersion()?->getKeyInfos() ?? [];
    }

    /**
     * @deprecated use method "getFilename()"
     */
    public function getObjectName()
    {
        return $this->getFilename();
    }

    /**
     * @return StoredObject::STATUS_*
     */
    public function getStatus(): string
    {
        return $this->status;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

    public function getType(): string
    {
        return $this->getCurrentVersion()?->getType() ?? '';
    }

    public function getUuid(): UuidInterface
    {
        return $this->uuid;
    }

    public function getWopiDocId(): string
    {
        return (string) $this->uuid;
    }

    /**
     * @deprecated
     */
    #[Serializer\Groups(['write'])]
    public function setCreationDate(\DateTime $creationDate): self
    {
        $this->createdAt = \DateTimeImmutable::createFromMutable($creationDate);

        return $this;
    }

    public function setDatas(?array $datas): self
    {
        $this->datas = (array) $datas;

        return $this;
    }

    /**
     * @param StoredObject::STATUS_* $status
     */
    public function setStatus(string $status): self
    {
        $this->status = $status;

        return $this;
    }

    public function setTitle(?string $title): self
    {
        $this->title = (string) $title;

        return $this;
    }

    public function getTemplate(): ?DocGeneratorTemplate
    {
        return $this->template;
    }

    /**
     * @return Selectable<int, StoredObjectVersion>&Collection<int, StoredObjectVersion>
     */
    public function getVersions(): Collection&Selectable
    {
        return $this->versions;
    }

    /**
     * Retrieves versions sorted by a given order.
     *
     * @param 'ASC'|'DESC' $order the sorting order, default is Order::Ascending
     *
     * @return ReadableCollection&Selectable The ordered collection of versions
     */
    public function getVersionsOrdered(string $order = 'ASC'): ReadableCollection&Selectable
    {
        $versions = $this->getVersions()->toArray();

        match ($order) {
            'ASC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $a->getVersion() <=> $b->getVersion()),
            'DESC' => usort($versions, static fn (StoredObjectVersion $a, StoredObjectVersion $b) => $b->getVersion() <=> $a->getVersion()),
        };

        return new ArrayCollection($versions);
    }

    public function hasCurrentVersion(): bool
    {
        return null !== $this->getCurrentVersion();
    }

    public function hasTemplate(): bool
    {
        return null !== $this->template;
    }

    /**
     * Checks if there is a version kept before conversion.
     *
     * @return bool true if a version is kept before conversion, false otherwise
     */
    public function hasKeptBeforeConversionVersion(): bool
    {
        foreach ($this->getVersions() as $version) {
            foreach ($version->getPointInTimes() as $pointInTime) {
                if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Retrieves the last version of the stored object that was kept before conversion.
     *
     * This method iterates through the ordered versions and their respective points
     * in time to find the most recent version that has a point in time with the reason
     * 'KEEP_BEFORE_CONVERSION'.
     *
     * @return StoredObjectVersion|null the version that was kept before conversion,
     *                                  or null if not found
     */
    public function getLastKeptBeforeConversionVersion(): ?StoredObjectVersion
    {
        foreach ($this->getVersionsOrdered('DESC') as $version) {
            foreach ($version->getPointInTimes() as $pointInTime) {
                if (StoredObjectPointInTimeReasonEnum::KEEP_BEFORE_CONVERSION === $pointInTime->getReason()) {
                    return $version;
                }
            }
        }

        return null;
    }

    public function setTemplate(?DocGeneratorTemplate $template): StoredObject
    {
        $this->template = $template;

        return $this;
    }

    public function isPending(): bool
    {
        return self::STATUS_PENDING === $this->getStatus();
    }

    public function isFailure(): bool
    {
        return self::STATUS_FAILURE === $this->getStatus();
    }

    public function getDeleteAt(): ?\DateTimeImmutable
    {
        return $this->deleteAt;
    }

    public function setDeleteAt(?\DateTimeImmutable $deleteAt): StoredObject
    {
        $this->deleteAt = $deleteAt;

        return $this;
    }

    public function getGenerationErrors(): string
    {
        return $this->generationErrors;
    }

    /**
     * Adds generation errors to the stored object.
     *
     * The existing generation errors are not removed
     *
     * @param string $generationErrors the generation errors to be added
     *
     * @return StoredObject the modified StoredObject instance
     */
    public function addGenerationErrors(string $generationErrors): StoredObject
    {
        $this->generationErrors = $this->generationErrors.$generationErrors."\n";

        return $this;
    }

    public function registerVersion(
        array $iv = [],
        array $keyInfos = [],
        string $type = '',
        ?string $filename = null,
    ): StoredObjectVersion {
        $version = new StoredObjectVersion(
            $this,
            null === $this->getCurrentVersion() ? 0 : $this->getCurrentVersion()->getVersion() + 1,
            $iv,
            $keyInfos,
            $type,
            $filename
        );

        $this->versions->add($version);

        if ('empty' === $this->status) {
            $this->status = self::STATUS_READY;
        }

        return $version;
    }

    public function removeVersion(StoredObjectVersion $storedObjectVersion): void
    {
        if (!$this->versions->contains($storedObjectVersion)) {
            throw new \UnexpectedValueException('This stored object does not contains this version');
        }
        $this->versions->removeElement($storedObjectVersion);
    }

    /**
     * @deprecated
     */
    public function saveHistory(): void {}

    public static function generatePrefix(): string
    {
        try {
            return base_convert(bin2hex(random_bytes(32)), 16, 36);
        } catch (RandomException) {
            return uniqid(more_entropy: true);
        }
    }

    /**
     * Checks if a stored object can be deleted.
     *
     * Currently, return true if the deletedAt date is below the current date, and the object
     * does not contains any version (which must be removed first).
     *
     * @param \DateTimeImmutable $now          the current date and time
     * @param StoredObject       $storedObject the stored object to check
     *
     * @return bool returns true if the stored object can be deleted, false otherwise
     */
    public static function canBeDeleted(\DateTimeImmutable $now, StoredObject $storedObject): bool
    {
        return $storedObject->getDeleteAt() < $now && $storedObject->getVersions()->isEmpty();
    }

    /**
     * Return true if it has a current version, and if the current version is encrypted.
     */
    public function isEncrypted(): bool
    {
        return $this->hasCurrentVersion() && $this->getCurrentVersion()->isEncrypted();
    }
}
