<?php
/**
 * OpenAI Stream
 *
 * Handles streaming responses from OpenAI API.
 * Migrated from integrations/openai/streams/OpenAIStream.php
 *
 * @package FleekDash\AI\Providers\OpenAI
 * @since 2.3.0
 */

namespace FleekDash\AI\Providers\OpenAI;

use FleekDash\Controllers\AI\AIChatDocumentPersistence;

defined('ABSPATH') || exit;

require_once __DIR__ . '/OpenAIStreamException.php';

/**
 * Class OpenAIStream
 *
 * Handles streaming responses from OpenAI API.
 */
class OpenAIStream
{
    private const SSE_DATA_PREFIX = 'data: ';

    /**
     * cURL handle
     *
     * @var resource|\CurlHandle
     */
    protected $ch;

    /**
     * Buffer for incomplete lines
     *
     * @var string
     */
    protected $buffer = '';

    /**
     * Pending document unique meta id
     *
     * @var string|null
     */
    private static $pendingDocumentMetaId = null;

    /**
     * Pending post ID (for frontend navigation)
     *
     * @var int|null
     */
    private static $pendingPostId = null;

    /**
     * Accumulated streamed text
     *
     * @var string
     */
    private static $accumulatedText = '';

    /**
     * Flag to track if completion was already signaled
     *
     * @var bool
     */
    private $completionSignaled = false;

    /**
     * Constructor
     *
     * @param resource|\CurlHandle $ch cURL handle
     */
    public function __construct($ch)
    {
        $this->ch = $ch;

        error_log('[OpenAIStream::__construct] Starting cURL request to OpenAI');

        // Set up the write callback for real streaming
        curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, [$this, 'writeCallback']);

        // Debug: dump the cURL info before executing
        $curlInfo = curl_getinfo($this->ch);
        error_log('[OpenAIStream::__construct] URL: ' . ($curlInfo['url'] ?? 'unknown'));
        error_log('[OpenAIStream::__construct] Is connecion: ' . json_encode($curlInfo));

        // Execute the request for real streaming
        $execResult = curl_exec($this->ch);

        $errno = curl_errno($this->ch);
        $error = curl_error($this->ch);
        error_log('[OpenAIStream::__construct] cURL exec returned. Errno: ' . $errno . ', error: ' . $error . ', execResult is bool: ' . var_export($execResult === false, true));

        // Lines without a trailing newline (common for single-line JSON error bodies) stay in $buffer; drain them.
        $this->drainWriteBuffer();

        if ($errno) {
            throw new OpenAIStreamException('Failed to connect to OpenAI API: ' . $error);
        }
    }

    /**
     * Write callback for processing streaming data
     *
     * @param resource|\CurlHandle $_ Discarded; required by CURLOPT_WRITEFUNCTION signature
     * @param string $data
     * @return int
     */
    public function writeCallback($_, $data)
    {
        // DEBUG: Log that writeCallback is being called
        if (defined('WP_DEBUG') && WP_DEBUG) {
            error_log('[OpenAIStream::writeCallback] Received ' . strlen($data) . ' bytes: ' . substr($data, 0, 200));
        }

        // Add the new data to our buffer
        $this->buffer .= $data;

        // Process complete lines from the buffer immediately
        while (($pos = strpos($this->buffer, "\n")) !== false) {
            // Extract the complete line
            $line = substr($this->buffer, 0, $pos);
            // Remove the processed line from the buffer
            $this->buffer = substr($this->buffer, $pos + 1);

            // Process and output the line immediately
            $this->processLine($line);
        }

        // Return the length of processed data
        return strlen($data);
    }

    /**
     * Flush remaining buffer after the transfer (handles final line without \n).
     */
    private function drainWriteBuffer(): void
    {
        while (($pos = strpos($this->buffer, "\n")) !== false) {
            $line = substr($this->buffer, 0, $pos);
            $this->buffer = substr($this->buffer, $pos + 1);
            $this->processLine($line);
        }
        if ($this->buffer !== '') {
            $this->processLine($this->buffer);
            $this->buffer = '';
        }
    }

    /**
     * Process a single line of the stream
     *
     * @param string $line
     */
    protected function processLine($line)
    {
        $line = trim($line);
        if (empty($line)) {
            return;
        }

        // Check if the line is a data line
        $prefixLen = strlen(self::SSE_DATA_PREFIX);
        if (strpos($line, self::SSE_DATA_PREFIX) === 0) {
            $data = substr($line, $prefixLen);

            // Check if it's the completion signal
            if ($data === '[DONE]') {
                if (!$this->completionSignaled) {
                    $this->completionSignaled = true;
                    self::sendCompletionSignal();
                }
                return;
            }

            $json = json_decode($data, true);
            if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
                $this->processJsonData($json);
            }
            return;
        }

        // Non-SSE JSON (e.g. HTTP 4xx/5xx body: {"error":{"message":"..."}})
        $this->tryProcessBareJsonLine($line);
    }

    /**
     * @param string $line
     */
    private function tryProcessBareJsonLine($line): void
    {
        $t = trim($line);
        if ($t === '' || ($t[0] ?? '') !== '{') {
            return;
        }
        $json = json_decode($t, true);
        if (json_last_error() === JSON_ERROR_NONE && is_array($json)) {
            $this->processJsonData($json);
        }
    }

    /**
     * Normalize OpenAI stream delta.content (string or structured parts) to plain text.
     *
     * @param mixed $raw
     * @return string
     */
    private static function normalizeStreamDeltaContent($raw)
    {
        $result = '';
        if (is_string($raw)) {
            $result = $raw;
        } elseif (is_array($raw)) {
            $parts = [];
            foreach ($raw as $item) {
                if (is_string($item)) {
                    $parts[] = $item;
                } elseif (is_array($item)) {
                    if (isset($item['text']) && is_string($item['text'])) {
                        $parts[] = $item['text'];
                    } elseif (isset($item['content']) && is_string($item['content'])) {
                        $parts[] = $item['content'];
                    }
                }
            }
            $result = implode('', $parts);
        }

        return $result;
    }

    /**
     * Process JSON data from the stream
     *
     * @param array $json
     */
    protected function processJsonData($json)
    {
        // Handle API-level errors
        if (isset($json['error'])) {
            $err = $json['error'];
            $msg = '';
            if (is_array($err)) {
                $msg = (string) ($err['message'] ?? '');
            } elseif (is_string($err)) {
                $msg = $err;
            }
            if ($msg !== '') {
                self::sendTextChunk('[ERROR] ' . $msg);
            }
            return;
        }

        // Extract text from stream delta (content and model-specific alternate keys)
        $delta = $json['choices'][0]['delta'] ?? null;
        if (!\is_array($delta)) {
            return;
        }

        $content = '';
        if (isset($delta['content'])) {
            $content = self::normalizeStreamDeltaContent($delta['content']);
        }
        if ($content === '' && isset($delta['reasoning_content'])) {
            $content = self::normalizeStreamDeltaContent($delta['reasoning_content']);
        }
        if ($content === '' && isset($delta['reasoning'])) {
            $content = self::normalizeStreamDeltaContent($delta['reasoning']);
        }

        if ($content === '') {
            return;
        }

        self::$accumulatedText .= $content;
        self::sendTextChunk($content);
    }

    /**
     * Destructor
     */
    public function __destruct()
    {
        // Signal completion if not already signaled
        if (!$this->completionSignaled) {
            $this->completionSignaled = true;
            self::sendCompletionSignal();
        }

        // Close the cURL handle when done
        if ($this->ch) {
            curl_close($this->ch);
        }
    }

    /**
     * Set up streaming headers
     */
    public static function setupStreamingHeaders()
    {
        error_log('[OpenAIStream::setupStreamingHeaders] Setting headers. OB level before: ' . ob_get_level());

        // Replace REST API default Content-Type: application/json (see WP_REST_Server::serve_request).
        header('Content-Type: text/event-stream; charset=utf-8', true);
        header('Cache-Control: no-cache, no-store, must-revalidate');
        header('Connection: keep-alive');
        header('X-Content-Type-Options: nosniff');
        header('Content-Encoding: none');
        header('x-vercel-ai-data-stream: v1');

        // Disable proxy/server buffering
        header('X-Accel-Buffering: no');

        // Turn off PHP output buffering and compression
        @ini_set('output_buffering', 'off');
        @ini_set('zlib.output_compression', '0');
        if (function_exists('apache_setenv')) {
            @apache_setenv('no-gzip', '1');
        }

        // End ALL active output buffers aggressively
        while (ob_get_level() > 0) {
            ob_end_clean();
        }
        ob_implicit_flush(true);
        // Final flush to ensure headers are sent
        if (function_exists('fastcgi_finish_request')) {
            // Do NOT call fastcgi_finish_request here - it would close the connection
        }
        flush();

        error_log('[OpenAIStream::setupStreamingHeaders] Done. OB level after: ' . ob_get_level());
    }

    /**
     * Set the pending document meta id and post id
     *
     * @param string $documentMetaId
     * @param int|null $postId Optional post ID for frontend navigation
     */
    public static function setPendingDocumentMetaId(string $documentMetaId, ?int $postId = null): void
    {
        if (!$documentMetaId) {
            return;
        }
        self::$pendingDocumentMetaId = $documentMetaId;
        self::$pendingPostId = $postId;
        self::$accumulatedText = '';
    }

    /**
     * Persist accumulated [DOCUMENT] content to WordPress post.
     * Delegates to AIChatDocumentPersistence for the actual extraction + persistence.
     */
    private static function updatePendingDocumentIfAny(): void
    {
        if (!self::$pendingDocumentMetaId) {
            return;
        }

        $persistence = new AIChatDocumentPersistence();
        $persistence->persistIfDocumentSectionPresent(self::$pendingDocumentMetaId, self::$accumulatedText);

        // Resolve post_id for metadata if not already set
        if (!self::$pendingPostId) {
            $posts = \get_posts([
                'post_type' => 'any',
                'post_status' => 'any',
                'numberposts' => 1,
                'meta_key' => 'document_id',
                'meta_value' => self::$pendingDocumentMetaId,
                'fields' => 'ids',
            ]);
            if (\is_array($posts) && !empty($posts)) {
                self::$pendingPostId = (int) $posts[0];
            }
        }

        self::$accumulatedText = '';
    }

    /**
     * Send a text chunk
     *
     * @param string $text
     */
    public static function sendTextChunk($text)
    {
        $data = [
            'type' => 'text-delta',
            'textDelta' => $text
        ];

        $output = self::SSE_DATA_PREFIX . json_encode($data) . "\n\n";

        if (defined('WP_DEBUG') && WP_DEBUG) {
            error_log('[OpenAIStream::sendTextChunk] Writing ' . strlen($output) . ' bytes to HTTP');
        }

        echo $output;
        @ob_flush();
        flush();
    }

    /**
     * Send a completion signal
     *
     * @param array $metadata Optional metadata
     */
    public static function sendCompletionSignal($metadata = [])
    {
        error_log('[OpenAIStream::sendCompletionSignal] Called');

        // Try to persist captured document content before signaling done
        self::updatePendingDocumentIfAny();

        // If we have a pending post_id, send it as metadata
        $finalMetadata = $metadata;
        if (self::$pendingPostId) {
            $finalMetadata['post_id'] = self::$pendingPostId;
            $finalMetadata['document_id'] = self::$pendingDocumentMetaId;
        }

        // Send metadata if available
        if (!empty($finalMetadata)) {
            $metadataJson = json_encode(['type' => 'metadata', 'data' => $finalMetadata]);
            echo self::SSE_DATA_PREFIX . $metadataJson . "\n\n";
            @ob_flush();
            flush();

            // Debug log (only in debug mode)
            if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                error_log('[OpenAIStream] Sending metadata: ' . $metadataJson);
            }
        } else {
            // Debug: Log if metadata should be sent but isn't
            if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
                error_log('[OpenAIStream] No metadata to send. pending_post_id: ' . (self::$pendingPostId ?? 'null') . ', pending_document_meta_id: ' . (self::$pendingDocumentMetaId ?? 'null'));
            }
        }

        echo self::SSE_DATA_PREFIX . "[DONE]\n\n";
        @ob_flush();
        flush();

        // Reset pending document info
        self::$pendingDocumentMetaId = null;
        self::$pendingPostId = null;
    }

}
