<?php
// Handle streaming requests and responses for Chatbot queries

//if ( ! defined( 'ABSPATH' ) ) exit; // Cannot place this here as it breaks javascript client code. Nonce check is performed below to prevent unauth access

header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');

require_once './vendor/autoload.php';
require_once './includes/twx_util.php';
require_once './includes/openai_include.php';
require_once './includes/knowledge_base_include.php';
require_once './includes/chatlog_include.php';

try {
	TWXChatUtil::loadWordpress();

	TWXChatUtil::log('stream_handler:: start', 'w');

	// Nonce check. Check if called from valid Plugin Browser session
	$nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
    if (!wp_verify_nonce($nonce, 'twx_chat_nonce')) {
		$ret = ['error' => 'Invalid nonce'];
		TWXChatUtil::log("stream_handler:: invalid nonce");
		twxchat_end_stream($ret);
		exit;
    }

	$options = get_option('twx_chat_options');

	// After Nonce check, get request params
	$assistantId = isset($_GET['assistantId']) ? sanitize_text_field(wp_unslash($_GET['assistantId'])) : ''; // Nonce check prior to this line (line 22)

	$threadId = isset($_GET['threadId']) ? sanitize_text_field(wp_unslash($_GET['threadId'])) : ''; // Nonce check prior to this line (line 22)
    if(empty($threadId) && empty($assistantId)) twxchat_end_stream(['error'=>'missing/empty param: threadId']);
    $message = isset($_GET['message']) ? sanitize_text_field(wp_unslash(urldecode($_GET['message']))) : ''; // Nonce check prior to this line (line 22)
    if(empty($message)) twxchat_end_stream(['error'=>'missing/empty param: message']);

	$isCommonQuestion = (isset($_GET['isCommonQuestion']) && $_GET['isCommonQuestion'] == 'true'); // Nonce check prior to this line (line 22)
	$knowledgeBaseSource = isset($_GET['knowledgeBaseSource']) ? sanitize_text_field(wp_unslash($_GET['knowledgeBaseSource'])) : ''; // Nonce check prior to this line (line 22)
    $showSources = isset($_GET['showSources']) ? sanitize_text_field(wp_unslash($_GET['showSources'])) : ''; // Nonce check prior to this line (line 22)
	$isVoiceInput = (isset($_GET['isVoiceInput']) && $_GET['isVoiceInput'] == 'true'); // Nonce check prior to this line (line 22)
	$llmModel = isset($_GET['llmModel']) ? sanitize_text_field(wp_unslash(urldecode($_GET['llmModel']))) : null; // Nonce check prior to this line (line 22)
	$chatbotId = isset($_GET['chatbotId']) ? sanitize_text_field(wp_unslash($_GET['chatbotId'])) : null; // Nonce check prior to this line (line 22)
	$chatbotAtts = TWXChatUtil::getChatbotAttributesById(['id'=>$chatbotId],true);

	$minSimilarity = isset($_GET['minSimilarity']) ? sanitize_text_field(wp_unslash($_GET['minSimilarity'])) : ''; // Nonce check prior to this line (line 22)
	if(!empty($minSimilarity)) {
		$minSimilarity = intval($minSimilarity)/100;
	}else{
		$minSimilarity = intval(($chatbotAtts['min_similarity'] ?? TWXCHAT_DEFAULT_MIN_SIMILARITY))/100;
	}
	$maxOutputTokens = $chatbotAtts['max_output_tokens'] ?? TWXCHAT_DEFAULT_MAX_OUTPUT_TOKENS;

	$extraInstructions = $chatbotAtts['extra_instructions'] ?? '';
	$startingComments = $chatbotAtts['starting_comments'] ?? '';
    
    $apiKey = $options['openai_apikey'];
	$similarKnowledgeBaseEntries = [];
	$estimated = [];
	$sources = [];
	$queryResponse = [];

	// User Fields (Plus Plan)
	$userFieldsWithVals = [];
	$numUserFieldsToCollect = 0;
	$userFieldsUpdateWebhookUrl = null;
	if(TWXChatUtil::customerPlanAccess('plus')){
		list($userFieldsWithVals, $numUserFieldsToCollect, $userFieldsUpdateWebhookUrl) = TWXChatPlus::getUserFieldsWithValues($chatbotId, $threadId);
	}

	if(empty($assistantId)){

	    $openai = new TWXChatOpenAIResponses($apiKey, $llmModel);
		//$completionsObject = new TWXChatOpenAICompletions($apiKey, $llmModel);
	    $model = $openai->getModel();    

		// Get params needed for LLM query
		list($newMessages, $similarKnowledgeBaseEntries, $estimated, $sources) = TWXChatUtil::getParamsForLLM($apiKey, $threadId, $extraInstructions, $startingComments, $message, $isCommonQuestion, $knowledgeBaseSource, $model, $minSimilarity, $showSources, $chatbotId, $userFieldsWithVals, $numUserFieldsToCollect);

		// run OpenAI query and let response stream to twxchat_handle_chunk function
		if(!empty($similarKnowledgeBaseEntries) || !empty($userFieldsWithVals)){
			// Begin streaming here
			TWXChatUtil::log("stream_handler:: start queryStream. maxOutputTokens = $maxOutputTokens. llmModel = $llmModel");
			$queryResponse = $openai->queryStream($newMessages, $maxOutputTokens, 'twxchat_handle_chunk');
		}else{
			$answer = $chatbotAtts['kb_no_match_text'] ?? __("I'm sorry. Please be a little more specific, or rephrase your question.",'twx-chat');
			twxchat_stream_handler_insertChatLog($threadId, $message, $isVoiceInput, false, $answer, $knowledgeBaseSource, [], $extraInstructions, $chatbotId);
			twxchat_end_stream(['answer' => $answer]);
		}

	}else{
		// Streaming Assistant Query
		$asstObject = new TWXChatOpenAIAssistant($apiKey, $assistantId);
		TWXChatUtil::log("stream_handler:: about to execute streaming query. assistant: $assistantId. thread: ".var_export($threadId,true));
		$queryResponse = $asstObject->queryAssistantStreaming($message, $threadId, 'twxchat_handle_assistant_chunk'); // stream response to fn

	}

    if(isset($queryResponse['error']['message'])){
        TWXChatUtil::log("stream_handler:: error: ".$queryResponse['error']['message']);
        $ret = ['error' => esc_html($queryResponse['error']['message'])];
        twxchat_stream_handler_insertChatLog($threadId, $message, $isVoiceInput, true, $queryResponse['error']['message'], $knowledgeBaseSource, $sources, $extraInstructions, $chatbotId);
        twxchat_end_stream($ret);
    }

	/* do we really need this in PROD??
	if(isset($queryResponse['fullStreamOutput'])){
		$parsedOutput = twxchat_extractConversationTextFromFullStreamOutput($queryResponse['fullStreamOutput']);
        TWXChatUtil::log("stream_handler:: fullStreamOutput: $parsedOutput");
	}
	*/

	// Chat Log insert for new user input, bot response will be updated from client later after stream completes
    twxchat_stream_handler_insertChatLog($threadId, $message, $isVoiceInput, false, '', $knowledgeBaseSource, $sources, $extraInstructions, $chatbotId);

    TWXChatUtil::log("stream_handler:: end");
    die();  // End the script

    
} catch (Exception $ex) {
    $ret = ['error' => esc_html($ex->getMessage())];
    TWXChatUtil::log("stream_handler:: exception: ".$ex->getMessage());
    twxchat_end_stream($ret);
}

function twxchat_handle_chunk($payload) {
	static $first_chunk = true;

	if ($first_chunk) {
		TWXChatUtil::log("stream_handler:: first chunk received");
		$first_chunk = false;
	}


	// Back-compat: accept either an array payload (preferred) or raw string
	if (is_array($payload)) {
		if(isset($payload['delta'])) $payload['delta'] = str_replace("\n", "<br/>", $payload['delta']);

//TODO remove below before release
//TWXChatUtil::log(var_export($payload,true));

		twxchat_emit_sse($payload);
		return;
	}

	// If something passes raw, try to pass it through safely (should be rare after changes below)
	echo $payload;

	if (ob_get_level() > 0) {
		@ob_flush();
	}
	flush();
}

function twxchat_handle_chunk_OLD($data_raw) {
	static $first_chunk = true;

	$data_escaped = twxchat_escape_chunk($data_raw);

	if ($first_chunk) {
		TWXChatUtil::log("stream_handler:: first chunk received");
		$first_chunk = false;
	}

	echo str_replace('\\n', "<br/>", $data_escaped);

	// Only flush if output buffering is active
	if (ob_get_level() > 0) {
		@ob_flush();
	}
	flush();
}

function twxchat_emit_sse(array $payload): void {
	// IMPORTANT: EventSource expects SSE format
	echo "data: " . wp_json_encode($payload) . "\n\n";

	if (ob_get_level() > 0) {
		@ob_flush();
	}
	flush();
}

function twxchat_handle_assistant_chunk($event) {
	TWXChatUtil::log("twxchat_handle_assistant_chunk:: ".var_export($event,true));
    // Only care about text deltas
    if (is_array($event) 
        && isset($event['event']) 
        && $event['event'] === 'thread.message.delta') {

        $delta = $event['data']['delta']['content'][0] ?? null;
        if ($delta && isset($delta['text']['value'])) {
            // Print (or collect) the streamed text
            echo $delta['text']['value'];
			
            flush(); // useful if outputting to browser
        }
    }

    // When stream ends
    if ($event === "[DONE]") {
		//TWXChatUtil::log("twxchat_handle_assistant_chunk:: STREAM FINISHED");
        echo "\n--- Stream finished ---\n";
    }
}

function twxchat_escape_chunk($chunk) {
	// only ecape the content that will be rendered otherwise will break the stream
	return preg_replace_callback(
		'/("delta"\s*:\s*\{\s*"content"\s*:\s*")([^"]*)(")/',
		function ($matches) {
			$escaped = wp_kses($matches[2], []);
			return $matches[1] . $escaped . $matches[3];
		},
		$chunk
	);
}

function twxchat_end_stream($ret) {

	if(is_array($ret)) $ret['end_of_stream'] = true;
    header('Connection: close');
	ob_implicit_flush(true);
	try { ob_end_flush(); }catch(Exception $error){}
	echo "data: " . wp_json_encode($ret) . "\n\n"; // $ret has been constructed within code (no user or LLM input) and is already escaped
	flush();  // Send the message immediately
    exit;
}

function twxchat_stream_handler_insertChatLog($threadId, $userInput, $isVoiceInput, $isError, $botResponse = '', $knowledgeBaseSource = '', $sources = '', $extraInstructions = '', $chatbotId = '' ) {

    $logData = [
		'userInput' => $userInput,
		'isVoiceInput' => ($isVoiceInput ? 'true' : 'false'),
		'wpUser' => TWXChatUtil::getWpUserLogin(),
	];
	//if(!empty($botCorrections)) $logData['botCorrections'] = $botCorrections;
	if(!empty($botResponse)) $logData['botResponse'] = $botResponse;
	if(!empty($knowledgeBaseSource)) $logData['knowledgeBaseSource'] = $knowledgeBaseSource;
	if(!empty($sources)) $logData['sources'] = $sources;
	if(!empty($extraInstructions)) $logData['extraInstructions'] = $extraInstructions;
	if(!empty($chatbotId)) $logData['chatbotId'] = $chatbotId;
	//if(!empty($assistantId)) $logData['assistantId'] = $assistantId;

	if (!TWXChatLog::hasRecentDuplicate($threadId, $logData)) {
		TWXChatLog::insertChatLog($threadId, $logData);
	}else{
		TWXChatUtil::adminLog("twxchat_stream_handler_insertChatLog:: recent duplicate chat log entry for threadId: $threadId -> " . var_export($logData,true));
	}

}

function twxchat_getPrevPrompts($messages){

	if(empty($messages)) return [];
	if(count($messages)==1) return [];
	
	$ret = [];

	for($i=count($messages)-2; $i>=0; $i--){
		$msg = $messages[$i];
		if($msg['role'] !== 'system'){
			$ret[] = $msg['content'];
		}
	}

	return $ret;
}

function twxchat_extractConversationTextFromFullStreamOutput($fullStreamOutput) {
    $lines = explode("\n", $fullStreamOutput);
    
	$fullText = '';

    foreach ($lines as $line) {
        $line = trim($line);
        if (strpos($line, 'data: ') !== 0) continue; // ignore non-data lines

        $payload = trim(substr($line, 6)); // remove "data: "

        if ($payload === '[DONE]') break;

        $json = json_decode($payload, true);
        if (!is_array($json)) continue;

        $content = $json['choices'][0]['delta']['content'] ?? '';
        $fullText .= $content;
    }

    return $fullText;
}

?>