<?php

declare(strict_types=1);

final class MyronUploadSessionService
{
    private const MAX_SESSION_BYTES = 536_870_912; // 512 MB

    public function __construct(
        private readonly string $baseDir,
    ) {
    }

    /**
     * @param array<string, mixed> $metadata
     * @return array<string, mixed>
     */
    public function begin(string $kind, string $filename, string $contentType, int $sizeBytes, string $sha256, array $metadata = []): array
    {
        $kind = trim($kind);
        $filename = basename(trim($filename));
        $contentType = trim($contentType);
        $sha256 = strtolower(trim($sha256));

        if ($kind === '') {
            throw new InvalidArgumentException('Upload session kind is required.');
        }
        if ($filename === '' || $filename === '.' || $filename === '..') {
            throw new InvalidArgumentException('filename is required.');
        }
        if ($contentType === '') {
            throw new InvalidArgumentException('contentType is required.');
        }
        if ($sizeBytes <= 0 || $sizeBytes > self::MAX_SESSION_BYTES) {
            throw new InvalidArgumentException('sizeBytes must be greater than zero and no more than 512 MB.');
        }
        if (!preg_match('/^[a-f0-9]{64}$/', $sha256)) {
            throw new InvalidArgumentException('sha256 must be a 64-character lowercase hex digest.');
        }

        $sessionId = 'upl_' . bin2hex(random_bytes(16));
        $session = [
            'sessionId' => $sessionId,
            'kind' => $kind,
            'filename' => $filename,
            'contentType' => $contentType,
            'sizeBytes' => $sizeBytes,
            'sha256' => $sha256,
            'receivedBytes' => 0,
            'metadata' => $metadata,
            'createdAt' => myronNow(),
            'updatedAt' => myronNow(),
        ];

        $this->ensureBaseDir();
        $this->writeSession($sessionId, $session);
        $dataPath = $this->dataPath($sessionId);
        if (file_put_contents($dataPath, '') === false) {
            $this->deleteSessionFiles($sessionId);
            throw new RuntimeException('Upload session storage could not be initialized.');
        }

        return $this->publicSession($session);
    }

    /**
     * @return array<string, mixed>
     */
    public function append(string $sessionId, string $expectedKind, int $offset, string $contentBase64, string $chunkSha256 = ''): array
    {
        $session = $this->loadSession($sessionId, $expectedKind);
        $offset = max(0, $offset);
        $contentBase64 = trim($contentBase64);
        if ($contentBase64 === '') {
            throw new InvalidArgumentException('contentBase64 is required.');
        }
        if (preg_match('/^data:[^;]+;base64,(.+)$/', $contentBase64, $matches) === 1) {
            $contentBase64 = (string) $matches[1];
        }

        $bytes = base64_decode($contentBase64, true);
        if (!is_string($bytes) || $bytes === '') {
            throw new InvalidArgumentException('contentBase64 is invalid.');
        }
        $chunkSha256 = strtolower(trim($chunkSha256));
        if ($chunkSha256 !== '' && !hash_equals($chunkSha256, hash('sha256', $bytes))) {
            throw new InvalidArgumentException('chunk sha256 does not match decoded content.');
        }

        $dataPath = $this->dataPath($sessionId);
        $currentSize = is_file($dataPath) ? (int) filesize($dataPath) : 0;
        if ($offset !== $currentSize) {
            throw new InvalidArgumentException('chunk offset must match the current received byte count.');
        }

        $declaredSize = (int) ($session['sizeBytes'] ?? 0);
        if ($currentSize + strlen($bytes) > $declaredSize) {
            throw new InvalidArgumentException('chunk would exceed the declared upload size.');
        }

        $handle = fopen($dataPath, 'ab');
        if (!is_resource($handle)) {
            throw new RuntimeException('Upload session storage could not be opened.');
        }
        try {
            $written = fwrite($handle, $bytes);
            if ($written !== strlen($bytes)) {
                throw new RuntimeException('Upload chunk could not be stored.');
            }
        } finally {
            fclose($handle);
        }

        $session['receivedBytes'] = $currentSize + strlen($bytes);
        $session['updatedAt'] = myronNow();
        $this->writeSession($sessionId, $session);

        return $this->publicSession($session);
    }

    /**
     * @return array{uploadedFile: array<string, mixed>, metadata: array<string, mixed>, bytes: string}
     */
    public function complete(string $sessionId, string $expectedKind, bool $returnBytes = false): array
    {
        $session = $this->loadSession($sessionId, $expectedKind);
        $dataPath = $this->dataPath($sessionId);
        if (!is_file($dataPath)) {
            throw new RuntimeException('Upload session payload is missing.');
        }
        $bytes = $returnBytes ? (string) file_get_contents($dataPath) : '';
        $size = (int) filesize($dataPath);
        $declaredSize = (int) ($session['sizeBytes'] ?? 0);
        if ($size !== $declaredSize) {
            throw new InvalidArgumentException('Upload session is incomplete.');
        }
        $actualSha = hash_file('sha256', $dataPath);
        if (!is_string($actualSha) || !hash_equals((string) ($session['sha256'] ?? ''), $actualSha)) {
            throw new InvalidArgumentException('Upload session sha256 does not match the declared digest.');
        }

        return [
            'uploadedFile' => [
                'error' => UPLOAD_ERR_OK,
                'tmp_name' => $dataPath,
                'name' => (string) ($session['filename'] ?? 'upload.bin'),
                'type' => (string) ($session['contentType'] ?? 'application/octet-stream'),
                'size' => $size,
            ],
            'metadata' => is_array($session['metadata'] ?? null) ? (array) $session['metadata'] : [],
            'bytes' => $bytes,
        ];
    }

    public function abort(string $sessionId, string $expectedKind = ''): array
    {
        if ($expectedKind !== '') {
            $this->loadSession($sessionId, $expectedKind);
        } else {
            $sessionId = $this->normalizeSessionId($sessionId);
        }
        $this->deleteSessionFiles($sessionId);
        return ['sessionId' => $sessionId, 'aborted' => true];
    }

    public function cleanup(string $sessionId): void
    {
        $sessionId = $this->normalizeSessionId($sessionId);
        $this->deleteSessionFiles($sessionId);
    }

    /**
     * @return array<string, mixed>
     */
    private function loadSession(string $sessionId, string $expectedKind): array
    {
        $sessionId = $this->normalizeSessionId($sessionId);
        $path = $this->metaPath($sessionId);
        if (!is_file($path)) {
            throw new InvalidArgumentException('Upload session was not found.');
        }
        $decoded = json_decode((string) file_get_contents($path), true);
        if (!is_array($decoded)) {
            throw new RuntimeException('Upload session metadata is invalid.');
        }
        if ($expectedKind !== '' && (string) ($decoded['kind'] ?? '') !== $expectedKind) {
            throw new InvalidArgumentException('Upload session kind does not match this endpoint.');
        }
        return $decoded;
    }

    /**
     * @param array<string, mixed> $session
     * @return array<string, mixed>
     */
    private function publicSession(array $session): array
    {
        $sizeBytes = (int) ($session['sizeBytes'] ?? 0);
        $receivedBytes = (int) ($session['receivedBytes'] ?? 0);
        return [
            'sessionId' => (string) ($session['sessionId'] ?? ''),
            'kind' => (string) ($session['kind'] ?? ''),
            'filename' => (string) ($session['filename'] ?? ''),
            'contentType' => (string) ($session['contentType'] ?? ''),
            'sizeBytes' => $sizeBytes,
            'receivedBytes' => $receivedBytes,
            'complete' => $sizeBytes > 0 && $receivedBytes === $sizeBytes,
            'sha256' => (string) ($session['sha256'] ?? ''),
        ];
    }

    /**
     * @param array<string, mixed> $session
     */
    private function writeSession(string $sessionId, array $session): void
    {
        $this->ensureBaseDir();
        if (file_put_contents($this->metaPath($sessionId), json_encode($session, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
            throw new RuntimeException('Upload session metadata could not be stored.');
        }
    }

    private function normalizeSessionId(string $sessionId): string
    {
        $sessionId = trim($sessionId);
        if (!preg_match('/^upl_[a-f0-9]{32}$/', $sessionId)) {
            throw new InvalidArgumentException('Upload session id is invalid.');
        }
        return $sessionId;
    }

    private function ensureBaseDir(): void
    {
        myronEnsureDirectory($this->baseDir);
    }

    private function metaPath(string $sessionId): string
    {
        return rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->normalizeSessionId($sessionId) . '.json';
    }

    private function dataPath(string $sessionId): string
    {
        return rtrim($this->baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $this->normalizeSessionId($sessionId) . '.bin';
    }

    private function deleteSessionFiles(string $sessionId): void
    {
        foreach ([$this->metaPath($sessionId), $this->dataPath($sessionId)] as $path) {
            if (is_file($path)) {
                @unlink($path);
            }
        }
    }
}
