<?php

namespace TusPhp\Tus;

use TusPhp\File;
use Carbon\Carbon;
use TusPhp\Request;
use TusPhp\Response;
use Ramsey\Uuid\Uuid;
use TusPhp\Cache\Cacheable;
use TusPhp\Events\UploadMerged;
use TusPhp\Events\UploadCreated;
use TusPhp\Events\UploadComplete;
use TusPhp\Events\UploadProgress;
use TusPhp\Middleware\Middleware;
use TusPhp\Exception\FileException;
use TusPhp\Exception\ConnectionException;
use TusPhp\Exception\OutOfRangeException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request as HttpRequest;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class Server extends AbstractTus
{
    /** @const string Tus Creation Extension */
    public const TUS_EXTENSION_CREATION = 'creation';

    /** @const string Tus Termination Extension */
    public const TUS_EXTENSION_TERMINATION = 'termination';

    /** @const string Tus Checksum Extension */
    public const TUS_EXTENSION_CHECKSUM = 'checksum';

    /** @const string Tus Expiration Extension */
    public const TUS_EXTENSION_EXPIRATION = 'expiration';

    /** @const string Tus Concatenation Extension */
    public const TUS_EXTENSION_CONCATENATION = 'concatenation';

    /** @const array All supported tus extensions */
    public const TUS_EXTENSIONS = [
        self::TUS_EXTENSION_CREATION,
        self::TUS_EXTENSION_TERMINATION,
        self::TUS_EXTENSION_CHECKSUM,
        self::TUS_EXTENSION_EXPIRATION,
        self::TUS_EXTENSION_CONCATENATION,
    ];

    /** @const int 460 Checksum Mismatch */
    private const HTTP_CHECKSUM_MISMATCH = 460;

    /** @const string Default checksum algorithm */
    private const DEFAULT_CHECKSUM_ALGORITHM = 'sha256';

    /** @var Request */
    protected $request;

    /** @var Response */
    protected $response;

    /** @var string */
    protected $uploadDir;

    /** @var string */
    protected $uploadKey;

    /** @var Middleware */
    protected $middleware;

    /**
     * @var int Max upload size in bytes
     *          Default 0, no restriction.
     */
    protected $maxUploadSize = 0;

    /**
     * TusServer constructor.
     *
     * @param Cacheable|string $cacheAdapter
     *
     * @throws \ReflectionException
     */
    public function __construct($cacheAdapter = 'file')
    {
        $this->request    = new Request();
        $this->response   = new Response();
        $this->middleware = new Middleware();
        $this->uploadDir  = \dirname(__DIR__, 2) . '/' . 'uploads';

        $this->setCache($cacheAdapter);
    }

    /**
     * Set upload dir.
     *
     * @param string $path
     *
     * @return Server
     */
    public function setUploadDir(string $path): self
    {
        $this->uploadDir = $path;

        return $this;
    }

    /**
     * Get upload dir.
     *
     * @return string
     */
    public function getUploadDir(): string
    {
        return $this->uploadDir;
    }

    /**
     * Get request.
     *
     * @return Request
     */
    public function getRequest(): Request
    {
        return $this->request;
    }

    /**
     * Get request.
     *
     * @return Response
     */
    public function getResponse(): Response
    {
        return $this->response;
    }

    /**
     * Get file checksum.
     *
     * @param string $filePath
     *
     * @return string
     */
    public function getServerChecksum(string $filePath): string
    {
        return hash_file($this->getChecksumAlgorithm(), $filePath);
    }

    /**
     * Get checksum algorithm.
     *
     * @return string|null
     */
    public function getChecksumAlgorithm(): ?string
    {
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');

        if (empty($checksumHeader)) {
            return self::DEFAULT_CHECKSUM_ALGORITHM;
        }

        [$checksumAlgorithm, /* $checksum */] = explode(' ', $checksumHeader);

        return $checksumAlgorithm;
    }

    /**
     * Set upload key.
     *
     * @param string $key
     *
     * @return Server
     */
    public function setUploadKey(string $key): self
    {
        $this->uploadKey = $key;

        return $this;
    }

    /**
     * Get upload key from header.
     *
     * @return string|HttpResponse
     */
    public function getUploadKey()
    {
        if ( ! empty($this->uploadKey)) {
            return $this->uploadKey;
        }

        $key = $this->getRequest()->header('Upload-Key') ?? Uuid::uuid4()->toString();

        if (empty($key)) {
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
        }

        $this->uploadKey = $key;

        return $this->uploadKey;
    }

    /**
     * Set middleware.
     *
     * @param Middleware $middleware
     *
     * @return self
     */
    public function setMiddleware(Middleware $middleware): self
    {
        $this->middleware = $middleware;

        return $this;
    }

    /**
     * Get middleware.
     *
     * @return Middleware
     */
    public function middleware(): Middleware
    {
        return $this->middleware;
    }

    /**
     * Set max upload size in bytes.
     *
     * @param int $uploadSize
     *
     * @return Server
     */
    public function setMaxUploadSize(int $uploadSize): self
    {
        $this->maxUploadSize = $uploadSize;

        return $this;
    }

    /**
     * Get max upload size.
     *
     * @return int
     */
    public function getMaxUploadSize(): int
    {
        return $this->maxUploadSize;
    }

    /**
     * Handle all HTTP request.
     *
     * @return HttpResponse|BinaryFileResponse
     */
    public function serve()
    {
        $this->applyMiddleware();

        $requestMethod = $this->getRequest()->method();

        if ( ! \in_array($requestMethod, $this->getRequest()->allowedHttpVerbs(), true)) {
            return $this->response->send(null, HttpResponse::HTTP_METHOD_NOT_ALLOWED);
        }

        $clientVersion = $this->getRequest()->header('Tus-Resumable');

        if (HttpRequest::METHOD_OPTIONS !== $requestMethod && $clientVersion && self::TUS_PROTOCOL_VERSION !== $clientVersion) {
            return $this->response->send(null, HttpResponse::HTTP_PRECONDITION_FAILED, [
                'Tus-Version' => self::TUS_PROTOCOL_VERSION,
            ]);
        }

        $method = 'handle' . ucfirst(strtolower($requestMethod));

        return $this->{$method}();
    }

    /**
     * Apply middleware.
     *
     * @return void
     */
    protected function applyMiddleware()
    {
        $middleware = $this->middleware()->list();

        foreach ($middleware as $m) {
            $m->handle($this->getRequest(), $this->getResponse());
        }
    }

    /**
     * Handle OPTIONS request.
     *
     * @return HttpResponse
     */
    protected function handleOptions(): HttpResponse
    {
        $headers = [
            'Allow' => implode(',', $this->request->allowedHttpVerbs()),
            'Tus-Version' => self::TUS_PROTOCOL_VERSION,
            'Tus-Extension' => implode(',', self::TUS_EXTENSIONS),
            'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(),
        ];

        $maxUploadSize = $this->getMaxUploadSize();

        if ($maxUploadSize > 0) {
            $headers['Tus-Max-Size'] = $maxUploadSize;
        }

        return $this->response->send(null, HttpResponse::HTTP_OK, $headers);
    }

    /**
     * Handle HEAD request.
     *
     * @return HttpResponse
     */
    protected function handleHead(): HttpResponse
    {
        $key = $this->request->key();

        if ( ! $fileMeta = $this->cache->get($key)) {
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
        }

        $offset = $fileMeta['offset'] ?? false;

        if (false === $offset) {
            return $this->response->send(null, HttpResponse::HTTP_GONE);
        }

        return $this->response->send(null, HttpResponse::HTTP_OK, $this->getHeadersForHeadRequest($fileMeta));
    }

    /**
     * Handle POST request.
     *
     * @return HttpResponse
     */
    protected function handlePost(): HttpResponse
    {
        $fileName   = $this->getRequest()->extractFileName();
        $uploadType = self::UPLOAD_TYPE_NORMAL;

        if (empty($fileName)) {
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
        }

        if ( ! $this->verifyUploadSize()) {
            return $this->response->send(null, HttpResponse::HTTP_REQUEST_ENTITY_TOO_LARGE);
        }

        $uploadKey = $this->getUploadKey();
        $filePath  = $this->uploadDir . '/' . $fileName;

        if ($this->getRequest()->isFinal()) {
            return $this->handleConcatenation($fileName, $filePath);
        }

        if ($this->getRequest()->isPartial()) {
            $filePath   = $this->getPathForPartialUpload($uploadKey) . $fileName;
            $uploadType = self::UPLOAD_TYPE_PARTIAL;
        }

        $checksum = $this->getClientChecksum();
        $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;

        $file = $this->buildFile([
            'name' => $fileName,
            'offset' => 0,
            'size' => $this->getRequest()->header('Upload-Length'),
            'file_path' => $filePath,
            'location' => $location,
        ])->setKey($uploadKey)->setChecksum($checksum)->setUploadMetadata($this->getRequest()->extractAllMeta());

        $this->cache->set($uploadKey, $file->details() + ['upload_type' => $uploadType]);

        $headers = [
            'Location' => $location,
            'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'],
        ];

        $this->event()->dispatch(
            new UploadCreated($file, $this->getRequest(), $this->getResponse()->setHeaders($headers)),
            UploadCreated::NAME
        );

        return $this->response->send(null, HttpResponse::HTTP_CREATED, $headers);
    }

    /**
     * Handle file concatenation.
     *
     * @param string $fileName
     * @param string $filePath
     *
     * @return HttpResponse
     */
    protected function handleConcatenation(string $fileName, string $filePath): HttpResponse
    {
        $partials  = $this->getRequest()->extractPartials();
        $uploadKey = $this->getUploadKey();
        $files     = $this->getPartialsMeta($partials);
        $filePaths = array_column($files, 'file_path');
        $location  = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;

        $file = $this->buildFile([
            'name' => $fileName,
            'offset' => 0,
            'size' => 0,
            'file_path' => $filePath,
            'location' => $location,
        ])->setFilePath($filePath)->setKey($uploadKey)->setUploadMetadata($this->getRequest()->extractAllMeta());

        $file->setOffset($file->merge($files));

        // Verify checksum.
        $checksum = $this->getServerChecksum($filePath);

        if ($checksum !== $this->getClientChecksum()) {
            return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
        }

        $file->setChecksum($checksum);
        $this->cache->set($uploadKey, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]);

        // Cleanup.
        if ($file->delete($filePaths, true)) {
            $this->cache->deleteAll($partials);
        }

        $this->event()->dispatch(
            new UploadMerged($file, $this->getRequest(), $this->getResponse()),
            UploadMerged::NAME
        );

        return $this->response->send(
            ['data' => ['checksum' => $checksum]],
            HttpResponse::HTTP_CREATED,
            [
                'Location' => $location,
            ]
        );
    }

    /**
     * Handle PATCH request.
     *
     * @return HttpResponse
     */
    protected function handlePatch(): HttpResponse
    {
        $uploadKey = $this->request->key();

        if ( ! $meta = $this->cache->get($uploadKey)) {
            return $this->response->send(null, HttpResponse::HTTP_GONE);
        }

        $status = $this->verifyPatchRequest($meta);

        if (HttpResponse::HTTP_OK !== $status) {
            return $this->response->send(null, $status);
        }

        $file     = $this->buildFile($meta)->setUploadMetadata($meta['metadata'] ?? []);
        $checksum = $meta['checksum'];

        try {
            $fileSize = $file->getFileSize();
            $offset   = $file->setKey($uploadKey)->setChecksum($checksum)->upload($fileSize);

            // If upload is done, verify checksum.
            if ($offset === $fileSize) {
                if ( ! $this->verifyChecksum($checksum, $meta['file_path'])) {
                    return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
                }

                $this->event()->dispatch(
                    new UploadComplete($file, $this->getRequest(), $this->getResponse()),
                    UploadComplete::NAME
                );
            } else {
                $this->event()->dispatch(
                    new UploadProgress($file, $this->getRequest(), $this->getResponse()),
                    UploadProgress::NAME
                );
            }
        } catch (FileException $e) {
            return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
        } catch (OutOfRangeException $e) {
            return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
        } catch (ConnectionException $e) {
            return $this->response->send(null, HttpResponse::HTTP_CONTINUE);
        }

        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
            'Content-Type' => self::HEADER_CONTENT_TYPE,
            'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'],
            'Upload-Offset' => $offset,
        ]);
    }

    /**
     * Verify PATCH request.
     *
     * @param array $meta
     *
     * @return int
     */
    protected function verifyPatchRequest(array $meta): int
    {
        if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) {
            return HttpResponse::HTTP_FORBIDDEN;
        }

        $uploadOffset = $this->request->header('upload-offset');

        if ($uploadOffset && $uploadOffset !== (string) $meta['offset']) {
            return HttpResponse::HTTP_CONFLICT;
        }

        $contentType = $this->request->header('Content-Type');

        if ($contentType !== self::HEADER_CONTENT_TYPE) {
            return HTTPRESPONSE::HTTP_UNSUPPORTED_MEDIA_TYPE;
        }

        return HttpResponse::HTTP_OK;
    }

    /**
     * Handle GET request.
     *
     * As per RFC7231, we need to treat HEAD and GET as an identical request.
     * All major PHP frameworks follows the same and silently transforms each
     * HEAD requests to GET.
     *
     * @return BinaryFileResponse|HttpResponse
     */
    protected function handleGet()
    {
        // We will treat '/files/<key>/get' as a download request.
        if ('get' === $this->request->key()) {
            return $this->handleDownload();
        }

        return $this->handleHead();
    }

    /**
     * Handle Download request.
     *
     * @return BinaryFileResponse|HttpResponse
     */
    protected function handleDownload()
    {
        $path = explode('/', str_replace('/get', '', $this->request->path()));
        $key  = end($path);

        if ( ! $fileMeta = $this->cache->get($key)) {
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
        }

        $resource = $fileMeta['file_path'] ?? null;
        $fileName = $fileMeta['name'] ?? null;

        if ( ! $resource || ! file_exists($resource)) {
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
        }

        return $this->response->download($resource, $fileName);
    }

    /**
     * Handle DELETE request.
     *
     * @return HttpResponse
     */
    protected function handleDelete(): HttpResponse
    {
        $key      = $this->request->key();
        $fileMeta = $this->cache->get($key);
        $resource = $fileMeta['file_path'] ?? null;

        if ( ! $resource) {
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
        }

        $isDeleted = $this->cache->delete($key);

        if ( ! $isDeleted || ! file_exists($resource)) {
            return $this->response->send(null, HttpResponse::HTTP_GONE);
        }

        unlink($resource);

        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
            'Tus-Extension' => self::TUS_EXTENSION_TERMINATION,
        ]);
    }

    /**
     * Get required headers for head request.
     *
     * @param array $fileMeta
     *
     * @return array
     */
    protected function getHeadersForHeadRequest(array $fileMeta): array
    {
        $headers = [
            'Upload-Length' => (int) $fileMeta['size'],
            'Upload-Offset' => (int) $fileMeta['offset'],
            'Cache-Control' => 'no-store',
        ];

        if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) {
            unset($headers['Upload-Offset']);
        }

        if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) {
            $headers += ['Upload-Concat' => $fileMeta['upload_type']];
        }

        return $headers;
    }

    /**
     * Build file object.
     *
     * @param array $meta
     *
     * @return File
     */
    protected function buildFile(array $meta): File
    {
        $file = new File($meta['name'], $this->cache);

        if (\array_key_exists('offset', $meta)) {
            $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']);
        }

        return $file;
    }

    /**
     * Get list of supported hash algorithms.
     *
     * @return string
     */
    protected function getSupportedHashAlgorithms(): string
    {
        $supportedAlgorithms = hash_algos();

        $algorithms = [];
        foreach ($supportedAlgorithms as $hashAlgo) {
            if (false !== strpos($hashAlgo, ',')) {
                $algorithms[] = "'{$hashAlgo}'";
            } else {
                $algorithms[] = $hashAlgo;
            }
        }

        return implode(',', $algorithms);
    }

    /**
     * Verify and get upload checksum from header.
     *
     * @return string|HttpResponse
     */
    protected function getClientChecksum()
    {
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');

        if (empty($checksumHeader)) {
            return '';
        }

        [$checksumAlgorithm, $checksum] = explode(' ', $checksumHeader);

        $checksum = base64_decode($checksum);

        if (false === $checksum || ! \in_array($checksumAlgorithm, hash_algos(), true)) {
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
        }

        return $checksum;
    }

    /**
     * Get expired but incomplete uploads.
     *
     * @param array|null $contents
     *
     * @return bool
     */
    protected function isExpired($contents): bool
    {
        if (empty($contents)) {
            return true;
        }

        $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now());

        if ($isExpired && $contents['offset'] !== $contents['size']) {
            return true;
        }

        return false;
    }

    /**
     * Get path for partial upload.
     *
     * @param string $key
     *
     * @return string
     */
    protected function getPathForPartialUpload(string $key): string
    {
        [$actualKey, /* $partialUploadKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);

        $path = $this->uploadDir . '/' . $actualKey . '/';

        if ( ! file_exists($path)) {
            mkdir($path);
        }

        return $path;
    }

    /**
     * Get metadata of partials.
     *
     * @param array $partials
     *
     * @return array
     */
    protected function getPartialsMeta(array $partials): array
    {
        $files = [];

        foreach ($partials as $partial) {
            $fileMeta = $this->getCache()->get($partial);

            $files[] = $fileMeta;
        }

        return $files;
    }

    /**
     * Delete expired resources.
     *
     * @return array
     */
    public function handleExpiration(): array
    {
        $deleted   = [];
        $cacheKeys = $this->cache->keys();

        foreach ($cacheKeys as $key) {
            $fileMeta = $this->cache->get($key, true);

            if ( ! $this->isExpired($fileMeta)) {
                continue;
            }

            if ( ! $this->cache->delete($key)) {
                continue;
            }

            if (is_writable($fileMeta['file_path'])) {
                unlink($fileMeta['file_path']);
            }

            $deleted[] = $fileMeta;
        }

        return $deleted;
    }

    /**
     * Verify max upload size.
     *
     * @return bool
     */
    protected function verifyUploadSize(): bool
    {
        $maxUploadSize = $this->getMaxUploadSize();

        if ($maxUploadSize > 0 && $this->getRequest()->header('Upload-Length') > $maxUploadSize) {
            return false;
        }

        return true;
    }

    /**
     * Verify checksum if available.
     *
     * @param string $checksum
     * @param string $filePath
     *
     * @return bool
     */
    protected function verifyChecksum(string $checksum, string $filePath): bool
    {
        // Skip if checksum is empty.
        if (empty($checksum)) {
            return true;
        }

        return $checksum === $this->getServerChecksum($filePath);
    }

    /**
     * No other methods are allowed.
     *
     * @param string $method
     * @param array  $params
     *
     * @return HttpResponse
     */
    public function __call(string $method, array $params)
    {
        return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
    }
}
