<?php

declare(strict_types=1);

namespace App\Framework\Http\Parser;

use App\Framework\Http\Parser\Exception\ParserSecurityException;
use App\Framework\Http\UploadedFile;
use App\Framework\Http\UploadError;

/**
 * True streaming parser using PHP generators for memory-efficient processing
 * Processes multipart data without loading entire request into memory
 */
final class StreamingParser
{
    private const int DEFAULT_CHUNK_SIZE = 8192; // 8KB chunks
    private const string BOUNDARY_PREFIX = '--';
    private const string CRLF = "\r\n";
    private const string DOUBLE_CRLF = "\r\n\r\n";

    public function __construct(
        private readonly ParserConfig $config = new ParserConfig()
    ) {
    }

    /**
     * Stream multipart data using generators
     * Each part is yielded as it's completely read from the stream
     *
     * @param resource $stream Input stream (e.g., php://input)
     * @param string $boundary Multipart boundary
     * @param int $chunkSize Size of chunks to read
     * @return \Generator<int, array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}>
     */
    public function streamMultipart($stream, string $boundary, int $chunkSize = self::DEFAULT_CHUNK_SIZE): \Generator
    {
        if (! is_resource($stream)) {
            throw new \InvalidArgumentException('First parameter must be a valid stream resource');
        }

        $partCount = 0;
        $buffer = '';
        $inPart = false;
        $currentPart = [];
        $partHeaders = [];
        $partData = '';
        $tempFile = null;

        // Boundary markers
        $boundaryDelimiter = self::BOUNDARY_PREFIX . $boundary;
        $finalBoundary = $boundaryDelimiter . self::BOUNDARY_PREFIX;

        while (! feof($stream)) {
            $chunk = fread($stream, $chunkSize);
            if ($chunk === false) {
                break;
            }

            $buffer .= $chunk;

            // Process buffer line by line
            while (($lineEnd = strpos($buffer, self::CRLF)) !== false) {
                $line = substr($buffer, 0, $lineEnd);
                $buffer = substr($buffer, $lineEnd + 2);

                // Check for boundary
                if ($line === $boundaryDelimiter || $line === $finalBoundary) {
                    // Yield previous part if exists
                    if ($inPart && ! empty($currentPart)) {
                        yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);

                        $partCount++;
                        if ($partCount > $this->config->maxFileCount) {
                            throw ParserSecurityException::maxPartsExceeded($partCount, $this->config->maxFileCount);
                        }
                    }

                    // Check if this is the final boundary
                    if ($line === $finalBoundary) {
                        return;
                    }

                    // Start new part
                    $inPart = true;
                    $currentPart = [];
                    $partHeaders = [];
                    $partData = '';
                    $tempFile = null;

                    continue;
                }

                // If we're in a part
                if ($inPart) {
                    // Still reading headers
                    if (empty($currentPart)) {
                        if ($line === '') {
                            // End of headers, parse them
                            $currentPart = $this->parsePartHeaders($partHeaders);

                            // If it's a file, create temp stream
                            if (isset($currentPart['filename'])) {
                                $tempFile = tmpfile();
                                if ($tempFile === false) {
                                    throw new \RuntimeException('Failed to create temporary file for upload');
                                }
                            }
                        } else {
                            // Collect header line
                            $partHeaders[] = $line;
                        }
                    } else {
                        // Reading part data
                        if (isset($currentPart['filename']) && $tempFile !== null) {
                            // Write to temp file for file uploads
                            fwrite($tempFile, $line . self::CRLF);
                        } else {
                            // Accumulate in memory for form fields
                            $partData .= $line . self::CRLF;

                            // Check field size limit
                            if (strlen($partData) > $this->config->maxFieldValueLength) {
                                throw ParserSecurityException::fieldValueTooLong(
                                    $currentPart['name'] ?? 'unknown',
                                    strlen($partData),
                                    $this->config->maxFieldValueLength
                                );
                            }
                        }
                    }
                }
            }

            // Handle remaining data in buffer that might be part of file content
            if ($inPart && ! empty($currentPart) && strlen($buffer) > 0) {
                // Check if buffer might contain a boundary
                $boundaryPos = strpos($buffer, $boundaryDelimiter);
                if ($boundaryPos === false || $boundaryPos > strlen($boundaryDelimiter)) {
                    // Safe to write some data
                    $safeLength = $boundaryPos !== false ? $boundaryPos : strlen($buffer) - strlen($boundaryDelimiter);
                    if ($safeLength > 0) {
                        $safeData = substr($buffer, 0, $safeLength);

                        if (isset($currentPart['filename']) && $tempFile !== null) {
                            fwrite($tempFile, $safeData);
                        } else {
                            $partData .= $safeData;
                        }

                        $buffer = substr($buffer, $safeLength);
                    }
                }
            }
        }

        // Yield last part if exists
        if ($inPart && ! empty($currentPart)) {
            yield $this->finalizePart($currentPart, $partHeaders, $partData, $tempFile);
        }
    }

    /**
     * Parse headers from a multipart part
     *
     * @param array<string> $headerLines
     * @return array<string, mixed>
     */
    private function parsePartHeaders(array $headerLines): array
    {
        $headers = [];
        $result = [];

        // Parse header lines
        foreach ($headerLines as $line) {
            if (strpos($line, ':') !== false) {
                [$name, $value] = explode(':', $line, 2);
                $headers[trim($name)] = trim($value);
            }
        }

        // Parse Content-Disposition
        if (isset($headers['Content-Disposition'])) {
            $disposition = $headers['Content-Disposition'];

            // Extract name
            if (preg_match('/name="([^"]+)"/', $disposition, $matches)) {
                $result['name'] = $matches[1];
            }

            // Extract filename
            if (preg_match('/filename="([^"]+)"/', $disposition, $matches)) {
                $result['filename'] = $matches[1];
            }
        }

        // Add content type if present
        if (isset($headers['Content-Type'])) {
            $result['content_type'] = $headers['Content-Type'];
        }

        $result['headers'] = $headers;

        return $result;
    }

    /**
     * Finalize a part for yielding
     *
     * @param array<string, mixed> $part
     * @param array<string> $headers
     * @param string $data
     * @param resource|null $tempFile
     * @return array{type: string, name: string, filename?: string, headers: array<string, string>, stream?: resource, data?: string}
     */
    private function finalizePart(array $part, array $headers, string $data, $tempFile): array
    {
        $result = [
            'type' => isset($part['filename']) ? 'file' : 'field',
            'name' => $part['name'] ?? 'unknown',
            'headers' => $part['headers'] ?? [],
        ];

        if (isset($part['filename'])) {
            $result['filename'] = $part['filename'];

            if ($tempFile !== null) {
                // Rewind temp file to beginning
                rewind($tempFile);
                $result['stream'] = $tempFile;

                // Validate file size
                $stats = fstat($tempFile);
                if ($stats !== false && $stats['size'] > $this->config->maxFileSize->toBytes()) {
                    fclose($tempFile);

                    throw ParserSecurityException::fileSizeExceeded(
                        $part['filename'],
                        $stats['size'],
                        $this->config->maxFileSize->toBytes()
                    );
                }
            }
        } else {
            // Remove trailing CRLF from field data
            $result['data'] = rtrim($data, "\r\n");
        }

        return $result;
    }

    /**
     * Create UploadedFile objects from a stream
     * This collects all file parts from the generator
     *
     * @param resource $stream
     * @param string $boundary
     * @return array<string, UploadedFile|array<UploadedFile>>
     */
    public function parseFilesFromStream($stream, string $boundary): array
    {
        $files = [];

        foreach ($this->streamMultipart($stream, $boundary) as $part) {
            if ($part['type'] === 'file' && isset($part['stream'])) {
                // Create temporary file path
                $tempPath = tempnam(sys_get_temp_dir(), 'upload_');
                if ($tempPath === false) {
                    throw new \RuntimeException('Failed to create temporary file');
                }

                // Copy stream to temp file
                $tempHandle = fopen($tempPath, 'w');
                if ($tempHandle === false) {
                    throw new \RuntimeException('Failed to open temporary file for writing');
                }

                stream_copy_to_stream($part['stream'], $tempHandle);
                fclose($tempHandle);
                fclose($part['stream']);

                // Get file size
                $fileSize = filesize($tempPath) ?: 0;

                // Create UploadedFile
                $uploadedFile = new UploadedFile(
                    name: $part['filename'] ?? '',
                    type: $part['headers']['Content-Type'] ?? 'application/octet-stream',
                    size: $fileSize,
                    tmpName: $tempPath,
                    error: UploadError::OK,
                    skipValidation: true
                );

                // Handle array notation in name
                $name = $part['name'];
                if (str_contains($name, '[')) {
                    // Parse array notation and build nested structure
                    $this->addArrayFile($files, $name, $uploadedFile);
                } else {
                    $files[$name] = $uploadedFile;
                }
            }
        }

        return $files;
    }

    /**
     * Add file to array structure based on field name
     *
     * @param array<string, mixed> &$files
     * @param string $name
     * @param UploadedFile $file
     */
    private function addArrayFile(array &$files, string $name, UploadedFile $file): void
    {
        // Parse array notation like "files[0]" or "files[avatar]"
        if (preg_match('/^([^\[]+)\[([^\]]*)\](.*)$/', $name, $matches)) {
            $baseName = $matches[1];
            $key = $matches[2];
            $remainder = $matches[3];

            if (! isset($files[$baseName])) {
                $files[$baseName] = [];
            }

            if ($remainder) {
                // Nested array
                if ($key === '') {
                    $key = count($files[$baseName]);
                }
                $this->addArrayFile($files[$baseName], $key . $remainder, $file);
            } else {
                // Final level
                if ($key === '') {
                    $files[$baseName][] = $file;
                } else {
                    $files[$baseName][$key] = $file;
                }
            }
        }
    }
}
