<?php

declare(strict_types=1);

namespace App\Services\Uploads;

use App\DTOs\Uploads\UploadChunkResult;
use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use RuntimeException;

final class UploadChunkService
{
    public function __construct(
        private readonly UploadStorageService $storage,
        private readonly UploadSessionRepository $sessions,
        private readonly UploadAuditService $audit
    ) {
    }

    public function appendChunk(string $sessionId, string $chunkPath, int $offset, int $chunkSize, int $totalSize, int $userId, string $ip): UploadChunkResult
    {
        $session = $this->sessions->getOrFail($sessionId);

        $this->ensureTmpPath($session->tempPath);
        $this->ensureWritable($session->tempPath);
        $this->ensureChunkReadable($chunkPath, $chunkSize);
        $this->ensureLimits($totalSize, $chunkSize);

        $lockSeconds = (int) config('uploads.chunk.lock_seconds', 10);
        $lockWait = (int) config('uploads.chunk.lock_wait_seconds', 5);
        $lock = Cache::lock('uploads:chunk:' . $sessionId, $lockSeconds);

        try {
            $lock->block($lockWait);
        } catch (\Throwable $e) {
            $this->audit->log($userId, 'upload_chunk_locked', $ip, [
                'session_id' => $sessionId,
            ]);
            throw new RuntimeException('Upload is busy. Please retry.');
        }

        try {
            $currentSize = (int) filesize($session->tempPath);

            if ($offset > $currentSize) {
                $this->audit->log($userId, 'upload_chunk_offset_mismatch', $ip, [
                    'session_id' => $sessionId,
                    'offset' => $offset,
                    'current_size' => $currentSize,
                ]);
                throw new RuntimeException('Invalid chunk offset.');
            }

            if ($offset < $currentSize) {
                if ($offset + $chunkSize <= $currentSize) {
                    return $this->finalizeResult($sessionId, $totalSize, $currentSize);
                }

                $this->audit->log($userId, 'upload_chunk_overlap', $ip, [
                    'session_id' => $sessionId,
                    'offset' => $offset,
                    'current_size' => $currentSize,
                ]);
                throw new RuntimeException('Chunk overlap detected.');
            }

            $written = $this->appendToFile($session->tempPath, $chunkPath, $offset, $chunkSize);
            $newSize = $currentSize + $written;

            if ($newSize > $totalSize) {
                $this->audit->log($userId, 'upload_chunk_size_exceeded', $ip, [
                    'session_id' => $sessionId,
                    'new_size' => $newSize,
                    'total_size' => $totalSize,
                ]);
                throw new RuntimeException('Upload exceeded expected size.');
            }

            $this->sessions->updateStatus($sessionId, UploadSessionStatus::TMP);
            $result = $this->finalizeResult($sessionId, $totalSize, $newSize);

            $this->audit->log($userId, 'upload_chunk_appended', $ip, [
                'session_id' => $sessionId,
                'received_bytes' => $newSize,
                'total_size' => $totalSize,
                'progress' => $result->progress,
            ]);

            return $result;
        } finally {
            optional($lock)->release();
        }
    }

    private function finalizeResult(string $sessionId, int $totalSize, int $currentSize): UploadChunkResult
    {
        $progress = $totalSize > 0 ? (int) floor(($currentSize / $totalSize) * 100) : 0;
        $progress = min(90, max(0, $progress));
        $this->sessions->updateProgress($sessionId, $progress);

        return new UploadChunkResult(
            $sessionId,
            UploadSessionStatus::TMP,
            $currentSize,
            $totalSize,
            $progress
        );
    }

    private function ensureTmpPath(string $path): void
    {
        $tmpRoot = $this->storage->sectionPath('tmp');
        $realRoot = realpath($tmpRoot);
        $realPath = realpath($path);

        if (! $realRoot || ! $realPath || strpos($realPath, $realRoot) !== 0) {
            throw new RuntimeException('Invalid temp path.');
        }
    }

    private function ensureWritable(string $path): void
    {
        if (! File::exists($path)) {
            File::put($path, '');
        }

        if (! is_writable($path)) {
            throw new RuntimeException('Upload path not writable.');
        }
    }

    private function ensureLimits(int $totalSize, int $chunkSize): void
    {
        $maxBytes = (int) config('uploads.max_size_mb', 0) * 1024 * 1024;
        if ($maxBytes > 0 && $totalSize > $maxBytes) {
            throw new RuntimeException('Upload exceeds max size.');
        }

        $maxChunk = (int) config('uploads.chunk.max_bytes', 0);
        if ($maxChunk > 0 && $chunkSize > $maxChunk) {
            throw new RuntimeException('Chunk exceeds max size.');
        }
    }

    private function ensureChunkReadable(string $chunkPath, int $chunkSize): void
    {
        $exists = is_file($chunkPath);
        $readable = $exists ? is_readable($chunkPath) : false;
        $actualSize = $exists ? (int) @filesize($chunkPath) : null;

        if (! $exists || ! $readable) {
            logger()->warning('Upload chunk unreadable or missing', [
                'chunk_path' => $chunkPath,
                'expected_size' => $chunkSize,
                'exists' => $exists,
                'readable' => $readable,
                'actual_size' => $actualSize,
            ]);
            throw new RuntimeException('Upload chunk missing.');
        }

        if ($actualSize !== $chunkSize) {
            logger()->warning('Upload chunk size mismatch', [
                'chunk_path' => $chunkPath,
                'expected_size' => $chunkSize,
                'actual_size' => $actualSize,
            ]);
            throw new RuntimeException('Chunk size mismatch.');
        }
    }

    private function appendToFile(string $targetPath, string $chunkPath, int $offset, int $chunkSize): int
    {
        $in = fopen($chunkPath, 'rb');
        if (! $in) {
            throw new RuntimeException('Unable to read upload chunk.');
        }

        $out = fopen($targetPath, 'c+b');
        if (! $out) {
            fclose($in);
            throw new RuntimeException('Unable to write upload chunk.');
        }

        if (fseek($out, $offset) !== 0) {
            fclose($in);
            fclose($out);
            throw new RuntimeException('Failed to seek in upload file.');
        }

        $written = stream_copy_to_stream($in, $out, $chunkSize);
        fflush($out);
        fclose($in);
        fclose($out);

        if ($written === false || (int) $written !== $chunkSize) {
            throw new RuntimeException('Incomplete chunk write.');
        }

        return (int) $written;
    }
}
