<?php

declare(strict_types=1);

/* For licensing terms, see /license.txt */

namespace Chamilo\CoreBundle\Controller;

use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceFile;
use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Helpers\AccessUrlHelper;
use Chamilo\CoreBundle\Helpers\UserHelper;
use Chamilo\CoreBundle\Repository\ResourceFileRepository;
use Chamilo\CoreBundle\Repository\ResourceNodeRepository;
use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface;
use Chamilo\CoreBundle\Repository\TrackEDownloadsRepository;
use Chamilo\CoreBundle\Security\Authorization\Voter\ResourceNodeVoter;
use Chamilo\CoreBundle\Settings\SettingsManager;
use Chamilo\CoreBundle\Tool\ToolChain;
use Chamilo\CoreBundle\Traits\ControllerTrait;
use Chamilo\CoreBundle\Traits\CourseControllerTrait;
use Chamilo\CoreBundle\Traits\GradebookControllerTrait;
use Chamilo\CoreBundle\Traits\ResourceControllerTrait;
use Chamilo\CourseBundle\Controller\CourseControllerInterface;
use Chamilo\CourseBundle\Entity\CTool;
use Chamilo\CourseBundle\Repository\CLinkRepository;
use Chamilo\CourseBundle\Repository\CShortcutRepository;
use Chamilo\CourseBundle\Repository\CToolRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Serializer\SerializerInterface;
use ZipStream\Option\Archive;
use ZipStream\ZipStream;

/**
 * @author Julio Montoya <gugli100@gmail.com>.
 */
#[Route('/r')]
class ResourceController extends AbstractResourceController implements CourseControllerInterface
{
    use ControllerTrait;
    use CourseControllerTrait;
    use GradebookControllerTrait;
    use ResourceControllerTrait;

    public function __construct(
        private readonly UserHelper $userHelper,
        private readonly ResourceNodeRepository $resourceNodeRepository,
        private readonly ResourceFileRepository $resourceFileRepository
    ) {}

    #[Route(path: '/{tool}/{type}/{id}/disk_space', methods: ['GET', 'POST'], name: 'chamilo_core_resource_disk_space')]
    public function diskSpace(Request $request): Response
    {
        $nodeId = $request->get('id');
        $repository = $this->getRepositoryFromRequest($request);

        /** @var ResourceNode $resourceNode */
        $resourceNode = $repository->getResourceNodeRepository()->find($nodeId);

        $this->denyAccessUnlessGranted(
            ResourceNodeVoter::VIEW,
            $resourceNode,
            $this->trans('Unauthorised access to resource')
        );

        $course = $this->getCourse();
        $totalSize = 0;
        if (null !== $course) {
            $totalSize = $course->getDiskQuota();
        }

        $size = $repository->getResourceNodeRepository()->getSize(
            $resourceNode,
            $repository->getResourceType(),
            $course
        );

        $labels[] = $course->getTitle();
        $data[] = $size;
        $sessions = $course->getSessions();

        foreach ($sessions as $sessionRelCourse) {
            $session = $sessionRelCourse->getSession();

            $labels[] = $course->getTitle().' - '.$session->getTitle();
            $size = $repository->getResourceNodeRepository()->getSize(
                $resourceNode,
                $repository->getResourceType(),
                $course,
                $session
            );
            $data[] = $size;
        }

        /*$groups = $course->getGroups();
        foreach ($groups as $group) {
            $labels[] = $course->getTitle().' - '.$group->getTitle();
            $size = $repository->getResourceNodeRepository()->getSize(
                $resourceNode,
                $repository->getResourceType(),
                $course,
                null,
                $group
            );
            $data[] = $size;
        }*/

        $used = array_sum($data);
        $labels[] = $this->trans('Free space on disk');
        $data[] = $totalSize - $used;

        return $this->render(
            '@ChamiloCore/Resource/disk_space.html.twig',
            [
                'resourceNode' => $resourceNode,
                'labels' => $labels,
                'data' => $data,
            ]
        );
    }

    /**
     * View file of a resource node.
     */
    #[Route('/{tool}/{type}/{id}/view', name: 'chamilo_core_resource_view', methods: ['GET'])]
    public function view(
        Request $request,
        TrackEDownloadsRepository $trackEDownloadsRepository,
        SettingsManager $settingsManager,
        AccessUrlHelper $accessUrlHelper
    ): Response {
        $id = $request->get('id');
        $resourceFileId = $request->get('resourceFileId');
        $filter = (string) $request->get('filter');
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);

        if (null === $resourceNode) {
            throw new FileNotFoundException($this->trans('Resource not found'));
        }

        $resourceFile = null;
        if ($resourceFileId) {
            $resourceFile = $this->resourceFileRepository->find($resourceFileId);
        }

        if (!$resourceFile) {
            $accessUrlSpecificFiles = $settingsManager->getSetting('document.access_url_specific_files') && $accessUrlHelper->isMultiple();
            $currentUrl = $accessUrlHelper->getCurrent()?->getUrl();

            $resourceFiles = $resourceNode->getResourceFiles();

            if ($accessUrlSpecificFiles) {
                foreach ($resourceFiles as $file) {
                    if ($file->getAccessUrl() && $file->getAccessUrl()->getUrl() === $currentUrl) {
                        $resourceFile = $file;

                        break;
                    }
                }
            }

            if (!$resourceFile) {
                $resourceFile = $resourceFiles->filter(fn ($file) => null === $file->getAccessUrl())->first();
            }
        }

        if (!$resourceFile) {
            throw new FileNotFoundException($this->trans('Resource file not found for the given resource node'));
        }

        $user = $this->userHelper->getCurrent();
        $firstResourceLink = $resourceNode->getResourceLinks()->first();
        if ($firstResourceLink && $user) {
            $url = $resourceFile->getOriginalName();
            $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
        }

        $cid = (int) $request->query->get('cid');
        $sid = (int) $request->query->get('sid');
        $allUserInfo = null;
        if ($cid && $user) {
            $allUserInfo = $this->getAllInfoToCertificate(
                $user->getId(),
                $cid,
                $sid,
                false
            );
        }

        return $this->processFile($request, $resourceNode, 'show', $filter, $allUserInfo, $resourceFile);
    }

    /**
     * Redirect resource to link.
     *
     * @return RedirectResponse|void
     */
    #[Route('/{tool}/{type}/{id}/link', name: 'chamilo_core_resource_link', methods: ['GET'])]
    public function link(Request $request, RouterInterface $router, CLinkRepository $cLinkRepository): RedirectResponse
    {
        $tool = $request->get('tool');
        $type = $request->get('type');
        $id = $request->get('id');
        $resourceNode = $this->getResourceNodeRepository()->find($id);

        if (null === $resourceNode) {
            throw new FileNotFoundException('Resource not found');
        }

        if ('course_tool' === $tool && 'links' === $type) {
            $cLink = $cLinkRepository->findOneBy(['resourceNode' => $resourceNode]);
            if ($cLink) {
                $url = $cLink->getUrl();

                return $this->redirect($url);
            }

            throw new FileNotFoundException('CLink not found for the given resource node');
        } else {
            $repo = $this->getRepositoryFromRequest($request);
            if ($repo instanceof ResourceWithLinkInterface) {
                $resource = $repo->getResourceFromResourceNode($resourceNode->getId());
                $url = $repo->getLink($resource, $router, $this->getCourseUrlQueryToArray());

                return $this->redirect($url);
            }

            $this->abort('No redirect');
        }
    }

    /**
     * Download file of a resource node.
     */
    #[Route('/{tool}/{type}/{id}/download', name: 'chamilo_core_resource_download', methods: ['GET'])]
    public function download(
        Request $request,
        TrackEDownloadsRepository $trackEDownloadsRepository,
        SettingsManager $settingsManager,
        AccessUrlHelper $accessUrlHelper
    ): Response {
        $id = $request->get('id');
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['uuid' => $id]);

        if (null === $resourceNode) {
            throw new FileNotFoundException($this->trans('Resource not found'));
        }

        $repo = $this->getRepositoryFromRequest($request);

        $this->denyAccessUnlessGranted(
            ResourceNodeVoter::VIEW,
            $resourceNode,
            $this->trans('Unauthorised access to resource')
        );

        $accessUrlSpecificFiles = $settingsManager->getSetting('document.access_url_specific_files') && $accessUrlHelper->isMultiple();
        $currentUrl = $accessUrlHelper->getCurrent()?->getUrl();

        $resourceFiles = $resourceNode->getResourceFiles();
        $resourceFile = null;

        if ($accessUrlSpecificFiles) {
            foreach ($resourceFiles as $file) {
                if ($file->getAccessUrl() && $file->getAccessUrl()->getUrl() === $currentUrl) {
                    $resourceFile = $file;

                    break;
                }
            }
        }

        $resourceFile ??= $resourceFiles->filter(fn ($file) => null === $file->getAccessUrl())->first();

        // If resource node has a file just download it. Don't download the children.
        if ($resourceFile) {
            $user = $this->userHelper->getCurrent();
            $firstResourceLink = $resourceNode->getResourceLinks()->first();

            if ($firstResourceLink && $user) {
                $url = $resourceFile->getOriginalName();
                $trackEDownloadsRepository->saveDownload($user, $firstResourceLink, $url);
            }

            // Redirect to download single file.
            return $this->processFile($request, $resourceNode, 'download', '', null, $resourceFile);
        }

        $zipName = $resourceNode->getSlug().'.zip';
        $resourceNodeRepo = $repo->getResourceNodeRepository();
        $type = $repo->getResourceType();

        $criteria = Criteria::create()
            ->where(Criteria::expr()->neq('resourceFiles', null)) // must have a file
            ->andWhere(Criteria::expr()->eq('resourceType', $type)) // only download same type
        ;

        $qb = $resourceNodeRepo->getChildrenQueryBuilder($resourceNode);
        $qbAlias = $qb->getRootAliases()[0];

        $qb
            ->leftJoin(\sprintf('%s.resourceFiles', $qbAlias), 'resourceFiles') // must have a file
            ->addCriteria($criteria)
        ;

        /** @var ArrayCollection|ResourceNode[] $children */
        $children = $qb->getQuery()->getResult();
        $count = \count($children);
        if (0 === $count) {
            $params = $this->getResourceParams($request);
            $params['id'] = $id;

            $this->addFlash('warning', $this->trans('No files'));

            return $this->redirectToRoute('chamilo_core_resource_list', $params);
        }

        $response = new StreamedResponse(
            function () use ($zipName, $children, $repo): void {
                // Define suitable options for ZipStream Archive.
                $options = new Archive();
                $options->setContentType('application/octet-stream');
                // initialise zipstream with output zip filename and options.
                $zip = new ZipStream($zipName, $options);

                /** @var ResourceNode $node */
                foreach ($children as $node) {
                    $resourceFiles = $node->getResourceFiles();
                    $resourceFile = $resourceFiles->filter(fn ($file) => null === $file->getAccessUrl())->first();

                    if ($resourceFile) {
                        $stream = $repo->getResourceNodeFileStream($node);
                        $fileName = $resourceFile->getOriginalName();
                        $zip->addFileFromStream($fileName, $stream);
                    }
                }
                $zip->finish();
            }
        );

        // Convert the file name to ASCII using iconv
        $zipName = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $zipName);

        $disposition = $response->headers->makeDisposition(
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
            $zipName // Transliterator::transliterate($zipName)
        );
        $response->headers->set('Content-Disposition', $disposition);
        $response->headers->set('Content-Type', 'application/octet-stream');

        return $response;
    }

    #[Route('/{tool}/{type}/{id}/change_visibility', name: 'chamilo_core_resource_change_visibility', methods: ['POST'])]
    public function changeVisibility(
        Request $request,
        EntityManagerInterface $entityManager,
        SerializerInterface $serializer,
        Security $security,
    ): Response {
        /** @var User $user */
        $user = $security->getUser();
        $isAdmin = ($user->isSuperAdmin() || $user->isAdmin());
        $isCourseTeacher = ($user->hasRole('ROLE_CURRENT_COURSE_TEACHER') || $user->hasRole('ROLE_CURRENT_COURSE_SESSION_TEACHER'));

        if (!($isCourseTeacher || $isAdmin)) {
            throw new AccessDeniedHttpException();
        }

        $session = null;
        if ($this->getSession()) {
            $sessionId = $this->getSession()->getId();
            $session = $entityManager->getRepository(Session::class)->find($sessionId);
        }
        $courseId = $this->getCourse()->getId();
        $course = $entityManager->getRepository(Course::class)->find($courseId);
        $id = $request->attributes->getInt('id');
        $resourceNode = $this->getResourceNodeRepository()->findOneBy(['id' => $id]);

        if (null === $resourceNode) {
            throw new NotFoundHttpException($this->trans('Resource not found'));
        }

        $link = null;
        foreach ($resourceNode->getResourceLinks() as $resourceLink) {
            if ($resourceLink->getSession() === $session) {
                $link = $resourceLink;

                break;
            }
        }

        if (null === $link) {
            $link = new ResourceLink();
            $link->setResourceNode($resourceNode)
                ->setSession($session)
                ->setCourse($course)
                ->setVisibility(ResourceLink::VISIBILITY_DRAFT)
            ;
            $entityManager->persist($link);
        } else {
            if (ResourceLink::VISIBILITY_PUBLISHED === $link->getVisibility()) {
                $link->setVisibility(ResourceLink::VISIBILITY_DRAFT);
            } else {
                $link->setVisibility(ResourceLink::VISIBILITY_PUBLISHED);
            }
        }

        $entityManager->flush();

        $json = $serializer->serialize(
            $link,
            'json',
            [
                'groups' => ['ctool:read'],
            ]
        );

        return JsonResponse::fromJsonString($json);
    }

    #[Route(
        '/{tool}/{type}/change_visibility/{visibility}',
        name: 'chamilo_core_resource_change_visibility_all',
        methods: ['POST']
    )]
    public function changeVisibilityAll(
        Request $request,
        CToolRepository $toolRepository,
        CShortcutRepository $shortcutRepository,
        ToolChain $toolChain,
        EntityManagerInterface $entityManager,
        Security $security
    ): Response {
        /** @var User $user */
        $user = $security->getUser();
        $isAdmin = ($user->isSuperAdmin() || $user->isAdmin());
        $isCourseTeacher = ($user->hasRole('ROLE_CURRENT_COURSE_TEACHER') || $user->hasRole('ROLE_CURRENT_COURSE_SESSION_TEACHER'));

        if (!($isCourseTeacher || $isAdmin)) {
            throw new AccessDeniedHttpException();
        }

        $visibility = $request->attributes->get('visibility');

        $session = null;
        if ($this->getSession()) {
            $sessionId = $this->getSession()->getId();
            $session = $entityManager->getRepository(Session::class)->find($sessionId);
        }
        $courseId = $this->getCourse()->getId();
        $course = $entityManager->getRepository(Course::class)->find($courseId);

        $result = $toolRepository->getResourcesByCourse($course, $session)
            ->addSelect('tool')
            ->innerJoin('resource.tool', 'tool')
            ->getQuery()
            ->getResult()
        ;

        $skipTools = ['course_tool',
            // 'chat',
            // 'notebook',
            // 'wiki'
        ];

        /** @var CTool $item */
        foreach ($result as $item) {
            if (\in_array($item->getTitle(), $skipTools, true)) {
                continue;
            }
            $toolModel = $toolChain->getToolFromName($item->getTool()->getTitle());

            if (!\in_array($toolModel->getCategory(), ['authoring', 'interaction'], true)) {
                continue;
            }

            $resourceNode = $item->getResourceNode();

            /** @var ResourceLink $link */
            $link = null;
            foreach ($resourceNode->getResourceLinks() as $resourceLink) {
                if ($resourceLink->getSession() === $session) {
                    $link = $resourceLink;

                    break;
                }
            }

            if (null === $link) {
                $link = new ResourceLink();
                $link->setResourceNode($resourceNode)
                    ->setSession($session)
                    ->setCourse($course)
                    ->setVisibility(ResourceLink::VISIBILITY_DRAFT)
                ;
                $entityManager->persist($link);
            }

            if ('show' === $visibility) {
                $link->setVisibility(ResourceLink::VISIBILITY_PUBLISHED);
            } elseif ('hide' === $visibility) {
                $link->setVisibility(ResourceLink::VISIBILITY_DRAFT);
            }
        }

        $entityManager->flush();

        return new Response(null, Response::HTTP_NO_CONTENT);
    }

    #[Route('/resource_files/{resourceNodeId}/variants', name: 'chamilo_core_resource_files_variants', methods: ['GET'])]
    public function getVariants(string $resourceNodeId, EntityManagerInterface $em): JsonResponse
    {
        $variants = $em->getRepository(ResourceFile::class)->createQueryBuilder('rf')
            ->join('rf.resourceNode', 'rn')
            ->leftJoin('rn.creator', 'creator')
            ->where('rf.resourceNode = :resourceNodeId')
            ->andWhere('rf.accessUrl IS NOT NULL')
            ->setParameter('resourceNodeId', $resourceNodeId)
            ->getQuery()
            ->getResult()
        ;

        $data = [];

        /** @var ResourceFile $variant */
        foreach ($variants as $variant) {
            $data[] = [
                'id' => $variant->getId(),
                'title' => $variant->getOriginalName(),
                'mimeType' => $variant->getMimeType(),
                'size' => $variant->getSize(),
                'updatedAt' => $variant->getUpdatedAt()->format('Y-m-d H:i:s'),
                'url' => $variant->getAccessUrl() ? $variant->getAccessUrl()->getUrl() : null,
                'path' => $this->resourceNodeRepository->getResourceFileUrl($variant->getResourceNode(), [], null, $variant),
                'creator' => $variant->getResourceNode()->getCreator() ? $variant->getResourceNode()->getCreator()->getFullName() : 'Unknown',
            ];
        }

        return $this->json($data);
    }

    #[Route('/resource_files/{id}/delete_variant', methods: ['DELETE'], name: 'chamilo_core_resource_files_delete_variant')]
    public function deleteVariant(int $id, EntityManagerInterface $em): JsonResponse
    {
        $variant = $em->getRepository(ResourceFile::class)->find($id);
        if (!$variant) {
            return $this->json(['error' => 'Variant not found'], Response::HTTP_NOT_FOUND);
        }

        $em->remove($variant);
        $em->flush();

        return $this->json(['success' => true]);
    }

    private function processFile(Request $request, ResourceNode $resourceNode, string $mode = 'show', string $filter = '', ?array $allUserInfo = null, ?ResourceFile $resourceFile = null): mixed
    {
        $this->denyAccessUnlessGranted(
            ResourceNodeVoter::VIEW,
            $resourceNode,
            $this->trans('Unauthorised view access to resource')
        );

        $resourceFile ??= $resourceNode->getResourceFiles()->first();

        if (!$resourceFile) {
            throw $this->createNotFoundException($this->trans('File not found for resource'));
        }

        $fileName = $resourceFile->getOriginalName();
        $fileSize = $resourceFile->getSize();
        $mimeType = $resourceFile->getMimeType();
        [$start, $end, $length] = $this->getRange($request, $fileSize);
        $resourceNodeRepo = $this->getResourceNodeRepository();

        // Convert the file name to ASCII using iconv
        $fileName = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $fileName);

        switch ($mode) {
            case 'download':
                $forceDownload = true;

                break;

            case 'show':
            default:
                $forceDownload = false;
                // If it's an image then send it to Glide.
                if (str_contains($mimeType, 'image')) {
                    $glide = $this->getGlide();
                    $server = $glide->getServer();
                    $params = $request->query->all();

                    // The filter overwrites the params from GET.
                    if (!empty($filter)) {
                        $params = $glide->getFilters()[$filter] ?? [];
                    }

                    // The image was cropped manually by the user, so we force to render this version,
                    // no matter other crop parameters.
                    $crop = $resourceFile->getCrop();
                    if (!empty($crop)) {
                        $params['crop'] = $crop;
                    }

                    $filePath = $resourceNodeRepo->getFilename($resourceFile);

                    $response = $server->getImageResponse($filePath, $params);

                    $disposition = $response->headers->makeDisposition(
                        ResponseHeaderBag::DISPOSITION_INLINE,
                        $fileName
                    );
                    $response->headers->set('Content-Disposition', $disposition);

                    return $response;
                }

                // Modify the HTML content before displaying it.
                if (str_contains($mimeType, 'html')) {
                    $content = $resourceNodeRepo->getResourceNodeFileContent($resourceNode, $resourceFile);

                    if (null !== $allUserInfo) {
                        $tagsToReplace = $allUserInfo[0];
                        $replacementValues = $allUserInfo[1];
                        $content = str_replace($tagsToReplace, $replacementValues, $content);
                    }

                    $response = new Response();
                    $disposition = $response->headers->makeDisposition(
                        ResponseHeaderBag::DISPOSITION_INLINE,
                        $fileName
                    );
                    $response->headers->set('Content-Disposition', $disposition);
                    $response->headers->set('Content-Type', 'text/html');

                    // @todo move into a function/class
                    if ('true' === $this->getSettingsManager()->getSetting('editor.translate_html')) {
                        $user = $this->userHelper->getCurrent();
                        if (null !== $user) {
                            // Overwrite user_json, otherwise it will be loaded by the TwigListener.php
                            $userJson = json_encode(['locale' => $user->getLocale()]);
                            $js = $this->renderView(
                                '@ChamiloCore/Layout/document.html.twig',
                                ['breadcrumb' => '', 'user_json' => $userJson]
                            );
                            // Insert inside the head tag.
                            $content = str_replace('</head>', $js.'</head>', $content);
                        }
                    }
                    $response->setContent($content);

                    return $response;
                }

                break;
        }

        $response = new StreamedResponse(
            function () use ($resourceNodeRepo, $resourceFile, $start, $length): void {
                $this->streamFileContent($resourceNodeRepo, $resourceFile, $start, $length);
            }
        );

        $disposition = $response->headers->makeDisposition(
            $forceDownload ? ResponseHeaderBag::DISPOSITION_ATTACHMENT : ResponseHeaderBag::DISPOSITION_INLINE,
            $fileName
        );
        $response->headers->set('Content-Disposition', $disposition);
        $response->headers->set('Content-Type', $mimeType ?: 'application/octet-stream');
        $response->headers->set('Content-Length', (string) $length);
        $response->headers->set('Accept-Ranges', 'bytes');
        $response->headers->set('Content-Range', "bytes $start-$end/$fileSize");
        $response->setStatusCode(
            $start > 0 || $end < $fileSize - 1 ? Response::HTTP_PARTIAL_CONTENT : Response::HTTP_OK
        );

        return $response;
    }

    private function getRange(Request $request, int $fileSize): array
    {
        $range = $request->headers->get('Range');

        if ($range) {
            [, $range] = explode('=', $range, 2);
            [$start, $end] = explode('-', $range);

            $start = (int) $start;
            $end = ('' === $end) ? $fileSize - 1 : (int) $end;

            $length = $end - $start + 1;
        } else {
            $start = 0;
            $end = $fileSize - 1;
            $length = $fileSize;
        }

        return [$start, $end, $length];
    }

    private function streamFileContent(ResourceNodeRepository $resourceNodeRepo, ResourceFile $resourceFile, int $start, int $length): void
    {
        $stream = $resourceNodeRepo->getResourceNodeFileStream($resourceFile->getResourceNode(), $resourceFile);

        fseek($stream, $start);

        $bytesSent = 0;

        while ($bytesSent < $length && !feof($stream)) {
            $buffer = fread($stream, min(1024 * 8, $length - $bytesSent));

            echo $buffer;

            $bytesSent += \strlen($buffer);
        }

        fclose($stream);
    }
}
