<?php declare(strict_types=1);
/*
 * Copyright (C) 2015-2017 IRSTEA
 * All rights reserved.
 */

namespace Irstea\FileUploadBundle\Entity;

use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Gaufrette\Exception\FileNotFound;
use Gaufrette\Filesystem;
use Gaufrette\StreamMode;
use InvalidArgumentException;
use Irstea\FileUploadBundle\Model\UploadedFileInterface;
use Irstea\FileUploadBundle\Utils\MimeTypeIcon;
use Rhumsaa\Uuid\Uuid;

/**
 * @ORM\Entity(repositoryClass="Irstea\FileUploadBundle\Entity\Repository\UploadedFileRepository")
 * @ORM\EntityListeners({
 *  "Irstea\FileUploadBundle\Listener\UploadedFileListener",
 *  "Irstea\FileUploadBundle\Listener\CreationDataListener"
 * })
 * @ORM\HasLifecycleCallbacks
 */
class UploadedFile implements UploadedFileInterface
{
    /**
     * Taille de bloc utilisé pour les copies.
     *
     * @var int
     */
    public static $copyBlockSize = 8192;

    public const ORPHAN_PREFIX = 'orphan/';

    /**
     * @ORM\Id
     * @ORM\Column(type="guid")
     *
     * @var string
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=1024)
     *
     * @var string
     */
    private $displayName;

    /**
     * @ORM\Column(type="string", length=1024)
     *
     * @var string
     */
    private $path;

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

    /**
     * @ORM\Column(type="string", length=255, nullable=true)
     *
     * @var string
     */
    private $mimeType;

    /**
     * @ORM\Column(type="integer", nullable=true)
     *
     * @var int
     */
    private $size;

    /**
     * @ORM\Column(type="string", length=64, nullable=true)
     *
     * @var string
     */
    private $checksum;

    /**
     * @ORM\Column(type="string", length=10)
     *
     * @var string
     */
    private $etat = self::ETAT_EN_COURS;

    /**
     * @ORM\Column(type="datetime")
     *
     * @var DateTime
     */
    private $createdAt;

    /**
     * @ORM\Column(type="string", nullable=true)
     *
     * @var string
     */
    private $createdBy;

    /**
     * @ORM\Column(type="string", nullable=true)
     *
     * @var string
     */
    private $createdFrom;

    /**
     * @ORM\Column(type="json_array", nullable=true)
     *
     * @var array
     */
    private $metadata;

    /**
     * @ORM\Column(type="string", length=256, nullable=true)
     *
     * @var string
     */
    private $description;

    /**
     * @var Filesystem
     */
    private $filesystem;

    /** Contient le nom de chemin local.
     * @var string
     */
    private $localTempPath;

    /** Crée un UploadedFile.
     * @internal
     */
    public function __construct()
    {
        $this->id = Uuid::uuid4()->toString();
        $this->actualPath = $this->path = self::ORPHAN_PREFIX . $this->id;
    }

    /**
     * {@inheritdoc}
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * {@inheritdoc}
     */
    public function setDisplayName($displayName)
    {
        $this->displayName = $displayName;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getDisplayName()
    {
        return $this->displayName;
    }

    /**
     * {@inheritdoc}
     */
    public function getPath()
    {
        return $this->path;
    }

    /**
     * @return string
     */
    public function getActualPath()
    {
        if (null === $this->actualPath) {
            $this->actualPath = $this->path;
        }

        return $this->actualPath;
    }

    /**
     * {@inheritdoc}
     */
    public function setPath($path)
    {
        if (!static::isSafePath($path)) {
            throw new InvalidArgumentException("Unsafe path: $path");
        }
        $this->path = trim($path, '/');

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function moveTo($newDir)
    {
        $this->setPath(rtrim($newDir, '/') . '/' . pathinfo($this->path, PATHINFO_FILENAME));
    }

    /**
     * {@inheritdoc}
     */
    public function setMimeType($mimeType)
    {
        $this->mimeType = $mimeType;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getMimeType()
    {
        return $this->mimeType;
    }

    /**
     * {@inheritdoc}
     */
    public function setSize($size)
    {
        $this->size = $size;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getSize()
    {
        return $this->size;
    }

    /**
     * {@inheritdoc}
     */
    public function setChecksum($checksum)
    {
        $this->checksum = $checksum;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getChecksum()
    {
        return $this->checksum;
    }

    /**
     * {@inheritdoc}
     */
    public function setEtat($etat)
    {
        if (!in_array(
            $etat,
            [
                self::ETAT_CORROMPU,
                self::ETAT_EN_COURS,
                self::ETAT_MANQUANT,
                self::ETAT_NORMAL,
                self::ETAT_ORPHELIN,
                self::ETAT_REJETE,
            ],
            true
        )) {
            throw new InvalidArgumentException(sprintf("Etat invalide: '%s'", (string) $etat));
        }

        // Déplace le fichier hors de l'orphelinat quand on passe d'orphelin à nouveau
        if ($this->etat === self::ETAT_ORPHELIN && $etat === self::ETAT_NORMAL && 0 === strpos($this->path, self::ORPHAN_PREFIX)) {
            $this->path = substr($this->path, strlen(self::ORPHAN_PREFIX));
        }

        $this->etat = $etat;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getEtat()
    {
        return $this->etat;
    }

    /**
     * {@inheritdoc}
     */
    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    /**
     * {@inheritdoc}
     */
    public function setMetadata(array $metadata = null)
    {
        $this->metadata = $metadata;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getMetadata()
    {
        return $this->metadata;
    }

    /**
     * {@inheritdoc}
     */
    public function __toString()
    {
        $unit = '';
        $size = $this->size ?: 0;
        if ($size >= 10240) {
            $size /= 1024;
            $unit = 'k';
            if ($size >= 10240) {
                $size /= 1024;
                $unit = 'm';
            }
        }

        return sprintf('%s (%s, %d%so)', $this->displayName, $this->mimeType ?: '?/?', $size, $unit);
    }

    /**
     * @param Filesystem $filesystem
     *
     * @return self
     *
     * @internal
     */
    public function setFilesystem(Filesystem $filesystem)
    {
        $this->filesystem = $filesystem;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function validate()
    {
        if (self::ETAT_EN_COURS === $this->getEtat()) {
            return;
        }

        $filesystem = $this->filesystem;
        $path = $this->getActualPath();

        if (!$filesystem->has($path)) {
            $this->setEtat(self::ETAT_MANQUANT);

            return;
        }

        if ($filesystem->size($path) !== $this->size || $filesystem->checksum($path) !== $this->checksum) {
            $this->setEtat(self::ETAT_CORROMPU);

            return;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function isValid()
    {
        return $this->getEtat() === self::ETAT_ORPHELIN || $this->getEtat() === self::ETAT_NORMAL;
    }

    /**
     * {@inheritdoc}
     */
    public function isOrphelin()
    {
        return $this->getEtat() === self::ETAT_ORPHELIN;
    }

    /**
     * {@inheritdoc}
     */
    public function getLastModified()
    {
        try {
            return new \DateTime(sprintf('@%d', $this->filesystem->mtime($this->getActualPath())));
        } catch (FileNotFound $ex) {
            return null;
        }
    }

    /**
     * {@inheritdoc}
     */
    public function getContent()
    {
        return $this->filesystem->read($this->getActualPath());
    }

    /**
     * {@inheritdoc}
     */
    public function setContent($content)
    {
        return $this->filesystem->write($this->getActualPath(), $content, true);
    }

    /**
     * {@inheritdoc}
     */
    public function copyFrom($source, $maxlen = -1, $writeOffset = 0)
    {
        if ($maxlen === 0) {
            return 0;
        }

        $stream = $this->filesystem->createStream($this->getActualPath());
        $stream->open(new StreamMode('cb'));
        $stream->seek($writeOffset);

        if (false !== $fileHandle = $stream->cast(STREAM_CAST_AS_STREAM)) {
            // Utilise stream_copy_to_stream si le Gaufrette\Stream peut nous retourner un filehandle
            $copied = stream_copy_to_stream($source, $fileHandle, $maxlen);
        } else {
            // Sinon fait une copie par blocs (moins performant)
            if ($maxlen === -1) {
                $maxlen = PHP_INT_MAX;
            }
            $copied = 0;
            while (!feof($source) && $copied <= $maxlen) {
                $copied += $stream->write(fread($source, min(static::$copyBlockSize, $maxlen - $copied)));
            }
        }
        $stream->close();

        return $copied;
    }

    /**
     * {@inheritdoc}
     */
    public function copyTo($dest, $maxlen = -1, $readOffset = 0)
    {
        if ($maxlen === -1) {
            $actualLength = $this->getSize() - $readOffset;
        } else {
            $actualLength = min($maxlen, $this->getSize() - $readOffset);
        }

        if ($actualLength <= 0) {
            return 0;
        }

        $stream = $this->filesystem->createStream($this->getActualPath());
        $stream->open(new StreamMode('rb'));
        $stream->seek($readOffset);

        if (false !== $fileHandle = $stream->cast(STREAM_CAST_AS_STREAM)) {
            // Utilise stream_copy_to_stream si le Stream nous renvoie un filehandle
            $copied = stream_copy_to_stream($fileHandle, $dest, $actualLength);
        } else {
            // Sinon, on fait ça à la main par blocs de 8ko
            $copied = 0;
            while (!$stream->eof() && $copied < $actualLength) {
                $copied += fwrite($dest, $stream->read(min(static::$copyBlockSize, $actualLength - $copied)));
            }
        }

        $stream->close();

        return $copied;
    }

    /** Vérifie si un chemin est "safe".
     * @param string $path
     *
     * @return bool
     *
     * @internal
     */
    public static function isSafePath($path)
    {
        /**
         * @return string
         */
        $parts = explode('/', trim($path, '/'));
        $level = 0;
        foreach ($parts as $part) {
            switch ($part) {
                case '.':
                    break;
                case '..':
                    $level--;
                    if ($level < 0) {
                        return false;
                    }
                    break;
                default:
                    $level++;
            }
        }

        return true;
    }

    /**
     * {@inheritdoc}
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * {@inheritdoc}
     */
    public function setDescription($description = null)
    {
        $this->description = $description;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function toArray()
    {
        return [
            'id'          => $this->getId(),
            'name'        => $this->getDisplayName(),
            'size'        => $this->getSize(),
            'type'        => $this->getMimeType(),
            'etat'        => $this->getEtat(),
            'description' => $this->getDescription(),
            'checksum'    => $this->getChecksum(),
            'icon'        => MimeTypeIcon::getMimeTypeIcon($this->getMimeType()),
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function getCreatedBy()
    {
        return $this->createdBy;
    }

    /**
     * {@inheritdoc}
     */
    public function getCreatedFrom()
    {
        return $this->createdFrom;
    }

    /**
     * {@inheritdoc}
     */
    public function setCreatedAt(DateTime $createdAt)
    {
        $this->createdAt = $createdAt;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function setCreatedBy($createdBy)
    {
        $this->createdBy = $createdBy;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function setCreatedFrom($createdFrom)
    {
        $this->createdFrom = $createdFrom;

        return $this;
    }

    /**
     * {@inheritdoc}
     */
    public function getLocalPath()
    {
        if (null !== $this->localTempPath) {
            return $this->localTempPath;
        }

        $stream = $this->filesystem->createStream($this->getActualPath());
        $stream->open(new StreamMode('rb'));
        $handle = $stream->cast(STREAM_CAST_AS_STREAM);

        if (false !== $handle) {
            if (stream_is_local($handle)) {
                $this->localTempPath = stream_get_meta_data($handle)['uri'];
                fclose($handle);

                return $this->localTempPath;
            }
            fclose($handle);
        }

        $this->localTempPath = tempnam(sys_get_temp_dir(), 'UploadedFile');
        register_shutdown_function('unlink', $this->localTempPath);

        $tmp = fopen($this->localTempPath, 'xb');
        $this->copyTo($tmp);
        fclose($tmp);

        return $this->localTempPath;
    }

    /**
     * Met à jour le chemin réel du fichier.
     *
     * @ORM\PostLoad
     * @ORM\PostPersist
     * @ORM\PostUpdate
     */
    public function updateActualPath()
    {
        $this->actualPath = $this->path;
    }

    /**
     * {@inheritdoc}
     * Ne compare que la taille et la somme de contrôle : il existe une très faible probabilité que deux fichiers de
     * même taille et même checksum soient différents.
     */
    public function hasSameContent(UploadedFileInterface $other)
    {
        return $other->getSize() === $this->getSize() && $other->getChecksum() === $this->getChecksum();
    }
}