<?php

namespace Common\Files\Tus;

use Common\Files\Tus\Exceptions\ConnectionException;
use Common\Files\Tus\Exceptions\FileException;
use Common\Files\Tus\Exceptions\OutOfRangeException;
use Common\Files\Tus\TusCache;
use Illuminate\Support\Facades\File;

class TusFile
{
    protected const INPUT_STREAM = 'php://input';
    protected const READ_BINARY = 'rb';
    protected const APPEND_BINARY = 'ab';

    protected int $offset = 0;
    protected int $totalBytes = 0;
    protected string $uploadKey;
    protected string $filePath;
    protected TusCache $cache;

    public function __construct(array $params)
    {
        $this->cache = app(TusCache::class);
        $this->uploadKey = $params['upload_key'];
        $this->totalBytes = $params['total_bytes'];
        $this->filePath = $params['file_path'];
        $this->offset = $params['offset'];
    }

    public function upload(): int
    {
        if ($this->offset === $this->totalBytes) {
            return $this->offset;
        }

        $method = config('common.site.uploads_tus_method') ?: 'wait';

        $input = $this->open(self::INPUT_STREAM, self::READ_BINARY);
        $output = $this->open($this->filePath, self::APPEND_BINARY);

        try {
            if ($method === 'loop') {
                $this->uploadUsingLoop($input, $output);
            } else {
                $this->uploadUsingCopyToStream($input, $output);
            }
        } finally {
            $this->cache->merge($this->uploadKey, [
                'offset' => $this->offset,
            ]);
            fclose($input);
            fclose($output);
        }

        return $this->offset;
    }

    /**
     * @param resource $input
     * @param resource $output
     */
    protected function uploadUsingLoop($input, $output): void
    {
        $chunkSize = 2097152; // 2MB
        $this->seek($output, $this->offset);

        while (!feof($input)) {
            if (CONNECTION_NORMAL !== connection_status()) {
                throw new ConnectionException('Connection aborted by user.');
            }

            // read
            $data = fread($input, $chunkSize);
            if ($data === false) {
                throw new FileException('Cannot read file.');
            }

            // write
            $bytesWritten = fwrite($output, $data, $chunkSize);
            if ($bytesWritten === false) {
                throw new FileException('Cannot write to a file.');
            }

            $this->offset += $bytesWritten;

            if ($this->offset > $this->totalBytes) {
                throw new OutOfRangeException('The uploaded file is corrupt.');
            }

            if ($this->offset === $this->totalBytes) {
                break;
            }
        }
    }

    /**
     * @param resource $input
     * @param resource $output
     */
    protected function uploadUsingCopyToStream($input, $output): void
    {
        $this->seek($output, $this->offset);

        $bytesWritten = stream_copy_to_stream($input, $output, null);

        if ($bytesWritten === false) {
            throw new FileException('Cannot write to a file.');
        }

        $this->offset += $bytesWritten;

        if ($this->offset > $this->totalBytes) {
            throw new OutOfRangeException('The uploaded file is corrupt.');
        }
    }

    protected function open(string $filePath, string $mode)
    {
        $this->exists($filePath, $mode);

        $resource = @fopen($filePath, $mode);

        if ($resource === false) {
            throw new FileException("Unable to open $filePath.");
        }

        return $resource;
    }

    public function exists(
        string $filePath,
        string $mode = self::READ_BINARY,
    ): bool {
        if (self::INPUT_STREAM === $filePath) {
            return true;
        }

        File::ensureDirectoryExists(storage_path('tus'));

        if (self::READ_BINARY === $mode && !file_exists($filePath)) {
            throw new FileException('File not found.');
        }

        return true;
    }

    public function seek($handle, int $offset): int
    {
        $position = fseek($handle, $offset);

        if (-1 === $position) {
            throw new FileException('Cannot move pointer to desired position.');
        }

        return $position;
    }
}
