<?php

declare(strict_types=1);

namespace LolaCore\Chat;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use LolaCore\AI\WPAIClientAdapter;
use LolaCore\Memory\MemoryManager;
use LolaCore\Memory\ContextBuilder;
use LolaCore\Scanner\SiteScanner;
use LolaCore\Scanner\ScanDataCollector;
use LolaCore\Actions\ActionExecutor;
use LolaCore\Actions\AbilitiesRegistry;
use LolaCore\I18n\ErrorMessages;

/**
 * Class Controller
 *
 * Primary entry point for chat interactions. Handles AJAX requests,
 * session management, and orchestrates the chat flow using WPAIClientAdapter.
 */
class Controller
{
    private MessageHandler $messageHandler;
    private ContextBuilder $contextBuilder;
    private SiteScanner $scanner;
    private MemoryManager $memoryManager;
    private WPAIClientAdapter $aiAdapter;
    private AbilitiesRegistry $abilitiesRegistry;

    public function __construct()
    {
        $this->memoryManager = new MemoryManager();
        $this->messageHandler = new MessageHandler($this->memoryManager);
        $this->contextBuilder = new ContextBuilder($this->memoryManager);
        $this->scanner = new SiteScanner(new ScanDataCollector(), $this->memoryManager);
        $this->abilitiesRegistry = new AbilitiesRegistry();
        
        $systemPromptBuilder = new SystemPromptBuilder();
        $this->aiAdapter = new WPAIClientAdapter(
            $this->contextBuilder,
            $this->scanner,
            $systemPromptBuilder,
            $this->abilitiesRegistry
        );
    }

    /**
     * Register AJAX hooks.
     */
    public function register(): void
    {
        \add_action('wp_ajax_lolacore_send_message', [$this, 'handleSendMessage']);
        \add_action('wp_ajax_lolacore_start_session', [$this, 'handleStartSession']);
        \add_action('wp_ajax_lolacore_close_session', [$this, 'handleCloseSession']);
        \add_action('wp_ajax_lolacore_get_history', [$this, 'handleGetHistory']);
        \add_action('wp_ajax_lolacore_confirm_tool_action', [$this, 'handleConfirmToolAction']);
        \add_action('wp_ajax_lolacore_complete_onboarding', [$this, 'handleCompleteOnboarding']);

        // Internal endpoint for deferred extractFacts work. Authenticated via
        // HMAC token (wp_salt), so cookies/sessions are not required — this
        // allows the call from wp_remote_post(..., blocking=false) to be served
        // by a fresh PHP-CGI worker without touching the user's session.
        \add_action('wp_ajax_lolacore_async_extract_facts', [$this, 'handleAsyncExtractFacts']);
        \add_action('wp_ajax_nopriv_lolacore_async_extract_facts', [$this, 'handleAsyncExtractFacts']);
    }

    /**
     * Start a new chat session.
     */
    public function handleStartSession(): void
    {
        $this->verifySecurity();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $sessionId = isset($_POST['session_id']) ? \sanitize_text_field(\wp_unslash($_POST['session_id'])) : null;
        if (!$sessionId) {
            \wp_send_json_error(['message' => ErrorMessages::sessionLost()]);
        }

        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_sessions';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->insert(
            $table,
            [
                'id'           => $sessionId,
                'status'       => 'active',
                'started_at'   => \current_time('mysql'),
            ]
        );

        // Refresh the site snapshot so this session sees current state.
        // Cheap relative to the LLM round-trip and runs once per session.
        try {
            $this->scanner->runFullScan();
        } catch (\Throwable $e) {
            // Non-fatal: a stale snapshot is preferable to blocking the chat.
        }

        \wp_send_json_success(['session_id' => $sessionId]);
    }

    /**
     * Mark onboarding as completed.
     * Called once after the welcome scan sequence finishes.
     */
    public function handleCompleteOnboarding(): void
    {
        $this->verifySecurity();

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table.
        $wpdb->update(
            $wpdb->prefix . 'lolacore_config',
            ['config_value' => '1'],
            ['config_key' => 'onboarding_complete'],
        );

        \wp_send_json_success(['onboarding_complete' => true]);
    }

    /**
     * Close current session.
     */
    public function handleCloseSession(): void
    {
        $this->verifySecurity();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $sessionId = isset($_POST['session_id']) ? \sanitize_text_field(\wp_unslash($_POST['session_id'])) : null;
        if (!$sessionId) {
            \wp_send_json_error(['message' => ErrorMessages::sessionLost()]);
        }

        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_sessions';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom LolaCore table; mutating row state, no cache to update.
        $wpdb->update(
            $table,
            ['status' => 'closed', 'closed_at' => \current_time('mysql')],
            ['id' => $sessionId]
        );

        \wp_cache_delete('lolacore_session_history_' . md5($sessionId), 'lolacore');

        $history = $this->getSessionHistory($sessionId);
        $closeSummary = $this->aiAdapter->generateSessionSummary($history);

        if ($closeSummary) {
            // Store as a config key for quick access (latest session)
            $configTable = $wpdb->prefix . 'lolacore_config';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom LolaCore table; key/value upsert.
            $wpdb->replace(
                $configTable, 
                [
                    'config_key' => 'last_session_summary', 
                    'config_value' => $closeSummary
                ]
            );

            // Also persist as a high-importance memory fact so summaries
            // are never overwritten and survive across multiple sessions.
            $memoryManager = new \LolaCore\Memory\MemoryManager();
            $memoryManager->saveFact(
                'session_summary',
                $closeSummary,
                8,
                'system',
                $sessionId
            );
        }

        \wp_send_json_success(['summary' => $closeSummary]);
    }

    /**
     * Get message history for a session.
     */
    public function handleGetHistory(): void
    {
        $this->verifySecurity();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $sessionId = isset($_POST['session_id']) ? \sanitize_text_field(\wp_unslash($_POST['session_id'])) : null;
        if (!$sessionId) {
            \wp_send_json_error(['message' => ErrorMessages::sessionLost()]);
        }

        $history = $this->getSessionHistory($sessionId);
        \wp_send_json_success(['messages' => $history]);
    }

    /**
     * Handle incoming chat message using standard AJAX JSON response.
     */
    public function handleSendMessage(): void
    {
        $this->verifySecurity();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $sessionId = isset($_POST['session_id']) ? \sanitize_text_field(\wp_unslash($_POST['session_id'])) : null;
        // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in verifySecurity(), message sanitized via MessageHandler::sanitize() below.
        $rawMessage = isset($_POST['message']) ? \sanitize_textarea_field(\wp_unslash($_POST['message'])) : null;

        if (!$sessionId || !$rawMessage) {
            \wp_send_json_error(['message' => ErrorMessages::sessionLost()]);
        }

        $message = $this->messageHandler->sanitize($rawMessage);
        
        $isFirstMessage = ($message === '__LOLA_ONBOARDING_INIT__');
        if (!$isFirstMessage) {
            $this->saveMessage($sessionId, 'user', $message);
        }
        $this->updateSessionLastMessage($sessionId);

        try {
            $history = $this->getSessionHistory($sessionId);
            $response = $this->aiAdapter->chat($message, $history, $sessionId);
            $this->processAiResponse($response, $sessionId, $message, $history);
        } catch (\Exception $e) {
            \wp_send_json_error(['message' => ErrorMessages::wrapServerError($e->getMessage())]);
        }
    }

    /**
     * Handle user confirmation (approve/cancel) for a pending write tool action.
     *
     * Supports both single-action and batch-action pending items. When the LLM
     * emits multiple write tool_calls in one response, they are stored as a batch.
     * Approving executes all items sequentially; cancelling denies them all.
     */
    public function handleConfirmToolAction(): void
    {
        $this->verifySecurity();

        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $actionId = isset($_POST['action_id']) ? \sanitize_text_field(\wp_unslash($_POST['action_id'])) : '';
        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $decision = isset($_POST['decision']) ? \sanitize_key(\wp_unslash($_POST['decision'])) : '';
        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        $sessionId = isset($_POST['session_id']) ? \sanitize_text_field(\wp_unslash($_POST['session_id'])) : '';

        if (empty($actionId) || empty($decision) || empty($sessionId)) {
            \wp_send_json_error(['message' => ErrorMessages::missingParameters()]);
        }

        $pending = $this->getPendingAction($actionId);
        if ($pending === null) {
            \wp_send_json_error(['message' => ErrorMessages::actionExpired()]);
        }

        $this->deletePendingAction($actionId);
        $executor = new ActionExecutor();

        // Determine if this is a batch or single action
        $batchItems = $pending['batch_items'] ?? null;

        if ($batchItems !== null) {
            // --- BATCH MODE: multiple write tools ---
            foreach ($batchItems as $item) {
                $toolName = $item['tool_name'];
                $args = $item['params'];
                $toolCallId = $item['tool_call_id'];

                if ($decision === 'cancel') {
                    $resultStr = 'The user denied execution of this action.';
                } else {
                    try {
                        $resultData = $executor->execute($toolName, $args, false);
                        $resultStr = is_string($resultData) ? $resultData : \wp_json_encode($resultData);
                    } catch (\Throwable $e) {
                        // Catching \Throwable (not just \Exception) is critical: if a TypeError
                        // or Error escapes here, the tool message never gets saved and the
                        // assistant's tool_call becomes orphan, breaking every future LLM turn
                        // in this session with a "insufficient tool messages" 400.
                        $resultStr = 'Execution failed: ' . $e->getMessage();
                    }
                }

                $this->saveMessage($sessionId, 'tool', $resultStr, [], $toolCallId, $item['tool_display_name'] ?? $toolName);
            }
        } else {
            // --- LEGACY SINGLE MODE ---
            $toolName = $pending['tool_name'];
            $args = $pending['params'];
            $toolCallId = $pending['tool_call_id'];

            if ($decision === 'cancel') {
                $resultStr = 'The user denied execution of this action.';
            } else {
                try {
                    $resultData = $executor->execute($toolName, $args, false);
                    $resultStr = is_string($resultData) ? $resultData : \wp_json_encode($resultData);
                } catch (\Throwable $e) {
                    $resultStr = 'Execution failed: ' . $e->getMessage();
                }
            }

            $this->saveMessage($sessionId, 'tool', $resultStr, [], $toolCallId, $toolName);
        }

        try {
            $history = $this->getSessionHistory($sessionId);
            $response = $this->aiAdapter->continueAfterToolExecution($history, $sessionId);
            $this->processAiResponse($response, $sessionId, null, $history);
        } catch (\Throwable $e) {
            // The tool actions already executed and saved above. Only the LLM
            // "summary" call failed. Return a success with a fallback message
            // so the user knows their changes went through.
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Critical diagnostic for post-confirmation LLM failures.
            error_log('[LolaCore] Post-confirmation LLM call failed: ' . $e->getMessage());
            \wp_send_json_success([
                'type'    => 'message',
                'content' => ErrorMessages::actionsCompletedSummaryFailed(),
            ]);
        }
    }

    /**
     * Centralized processing of the AI response logic (handles tool execution loop).
     *
     * When the LLM emits multiple write tool_calls in a single response (e.g. 3Ã—
     * updatePlugin), they are batched into a single grouped pending action so the
     * user only needs to approve once to execute them all sequentially.
     */
    private function processAiResponse(array $response, string $sessionId, ?string $userMessage, array $history = []): void
    {
        $maxLoops = 3;
        $currentLoop = 0;
        $executor = new ActionExecutor();

        // Source of truth for read/write classification: the action registry, which
        // includes core actions plus any registered by addons via `lolacore_available_actions`.
        // Tool names from the LLM are camelCase (e.g. createSnippet) and registry IDs
        // are snake_case (e.g. create_snippet); camelToSnake() bridges them.
        $actionRegistry = $executor->getActionRegistry();

        while ($currentLoop < $maxLoops) {
            $content = $response['content'] ?? '';
            $toolCalls = $response['tool_calls'] ?? [];

            if (!empty($content) || !empty($toolCalls)) {
                $reasoningContent = $response['reasoning_content'] ?? '';
                $this->saveMessage($sessionId, 'assistant', $content, $toolCalls, '', '', $reasoningContent);
            }

            if ($userMessage !== null && !empty($content)) {
                $this->maybeDeferExtractFacts($userMessage, $content, $sessionId, $history);
            }

            if (empty($toolCalls)) {
                // Done
                \wp_send_json_success([
                    'type' => 'message',
                    'content' => $content,
                    'usage' => $response['usage'] ?? []
                ]);
                return;
            }

            $safetyConfig = \LolaCore\Chat\SafetyConfig::fromDatabase();

            // --- META-TOOL: Tool Escalation Interceptor ---
            // If Lola calls requestFullToolset, bypass the normal flow and
            // re-process the original message with ALL available tools.
            $escalationCall = null;
            foreach ($toolCalls as $tc) {
                if (($tc['function']['name'] ?? '') === 'requestFullToolset') {
                    $escalationCall = $tc;
                    break;
                }
            }

            if ($escalationCall !== null) {
                $escalationArgs = json_decode($escalationCall['function']['arguments'] ?? '{}', true) ?? [];
                $reason = $escalationArgs['reason'] ?? 'No reason provided';

                // Rate-limit: max 1 escalation per session
                $escalationKey = 'lolacore_escalated_' . md5($sessionId);
                if (\get_transient($escalationKey)) {
                    // Already escalated â€” return the tool result saying so
                    $this->saveMessage($sessionId, 'tool', 'Escalation already used in this session. Work with the tools you have, or ask the user for help.', [], $escalationCall['id'], 'requestFullToolset');
                    $history = $this->getSessionHistory($sessionId);
                    $response = $this->aiAdapter->continueAfterToolExecution($history, $sessionId);
                    $currentLoop++;
                    continue;
                }

                // Mark session as escalated
                \set_transient($escalationKey, true, 12 * HOUR_IN_SECONDS);

                // Log the escalation for domain map improvement
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Escalation telemetry for router improvement.
                if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[LolaCore Escalation] Session: ' . $sessionId . ' | Reason: ' . $reason); }

                // Save the tool result acknowledging the escalation
                $this->saveMessage($sessionId, 'tool', 'Escalation granted. Re-processing your request with the full toolset.', [], $escalationCall['id'], 'requestFullToolset');

                // Re-process with full toolset â€” extract the last user message from history
                $lastUserMessage = null;
                foreach (array_reverse($history) as $msg) {
                    if (($msg['role'] ?? '') === 'user' && !empty($msg['content'])) {
                        $lastUserMessage = $msg['content'];
                        break;
                    }
                }

                if ($lastUserMessage === null) {
                    $lastUserMessage = $userMessage ?? '';
                }

                try {
                    $fullHistory = $this->getSessionHistory($sessionId);
                    $response = $this->aiAdapter->chatWithFullToolset($lastUserMessage, $fullHistory, $sessionId);
                    $this->processAiResponse($response, $sessionId, null, $fullHistory);
                } catch (\Exception $e) {
                    \wp_send_json_error(['message' => ErrorMessages::escalationFailed($e->getMessage())]);
                }
                return;
            }

            // Separate tool calls into auto-execute and write (need confirmation)
            $writeToolCalls = [];
            $autoToolCalls = [];

            foreach ($toolCalls as $tc) {
                $toolName = $tc['function']['name'] ?? '';
                $executorActionId = $this->camelToSnake($toolName);
                $isWrite = ($actionRegistry[$executorActionId]['type'] ?? 'read') === 'write';
                
                if ($isWrite && $safetyConfig->confirmWrites) {
                    $writeToolCalls[] = $tc;
                } else {
                    $autoToolCalls[] = [
                        'call' => $tc,
                        'isWrite' => $isWrite
                    ];
                }
            }

            // Execute all auto-execute tools immediately
            foreach ($autoToolCalls as $item) {
                $tc = $item['call'];
                $isWrite = $item['isWrite'];

                $toolName = $tc['function']['name'] ?? '';
                $args = json_decode($tc['function']['arguments'] ?? '{}', true) ?? [];
                $executorActionId = $this->camelToSnake($toolName);

                try {
                    $resultData = $executor->execute($executorActionId, $args, !$isWrite);
                    $resultStr = is_string($resultData) ? $resultData : \wp_json_encode($resultData);
                } catch (\Throwable $e) {
                    // \Throwable here (not \Exception) so that a TypeError in any handler
                    // still produces a tool message; otherwise the tool_call goes orphan
                    // and poisons every subsequent continueAfterToolExecution.
                    $resultStr = "Error executing tool: " . $e->getMessage();
                }

                $this->saveMessage($sessionId, 'tool', $resultStr, [], $tc['id'], $toolName);
            }

            // If there are write tools, batch them into a single confirmation request
            if (!empty($writeToolCalls)) {
                $batchId = \wp_generate_uuid4();
                $batchItems = [];
                $previewLines = [];

                foreach ($writeToolCalls as $tc) {
                    $toolName = $tc['function']['name'] ?? '';
                    $args = json_decode($tc['function']['arguments'] ?? '{}', true) ?? [];
                    $executorActionId = $this->camelToSnake($toolName);

                    try {
                        $preview = $executor->execute($executorActionId, $args, true);
                        $previewMsg = $preview['message'] ?? '';

                        $previewData = isset($preview['result']) ? (array) $preview['result'] : [];
                        $formattedPreview = [];
                        if (!empty($previewData)) {
                            $formattedPreview[] = "**Preview ({$executorActionId}):**";
                            foreach ($previewData as $k => $v) {
                                if (is_scalar($v)) {
                                    $formattedPreview[] = "- **{$k}:** " . esc_html((string)$v);
                                } elseif (is_array($v) || is_object($v)) {
                                    $formattedPreview[] = "- **{$k}:** " . esc_html(json_encode($v, JSON_UNESCAPED_UNICODE));
                                }
                            }
                            $previewMsg = implode("\n", $formattedPreview);
                        }
                    } catch (\Throwable $e) {
                        $previewMsg = "**Preview error:** " . $e->getMessage();
                        $preview = [];
                    }

                    $batchItems[] = [
                        'tool_name' => $executorActionId,
                        'tool_display_name' => $toolName,
                        'params' => $args,
                        'tool_call_id' => $tc['id'],
                        'preview' => $previewMsg,
                        'preview_data' => $preview['result'] ?? null,
                    ];

                    $label = $toolName;
                    if (!empty($args['slug'])) {
                        $label .= " ({$args['slug']})";
                    } elseif (!empty($args['slugs']) && is_array($args['slugs'])) {
                        $label .= " (" . implode(', ', $args['slugs']) . ")";
                    } elseif ($toolName === 'bulkUpdatePlugins') {
                        $pluginNames = [];
                        if (!empty($preview['result']->plugins) && is_array($preview['result']->plugins)) {
                            foreach ($preview['result']->plugins as $p) {
                                if (!empty($p['name'])) {
                                    $pluginNames[] = $p['name'];
                                } elseif (!empty($p['slug'])) {
                                    $pluginNames[] = $p['slug'];
                                }
                            }
                        }
                        if (!empty($pluginNames)) {
                            $label .= " (" . implode(', ', $pluginNames) . ")";
                        } else {
                            $label .= " (All pending updates)";
                        }
                    }
                    $previewLines[] = "- {$label}";
                }

                $count = count($batchItems);
                $message = $count === 1
                    ? "Lola requests permission to execute: {$batchItems[0]['tool_display_name']}"
                    : "Lola requests permission to execute {$count} actions";

                $displayParams = $count === 1 ? $batchItems[0]['params'] : ['operations' => implode("\n", $previewLines)];
                
                // Enhance display params for bulkUpdatePlugins so UI doesn't just show an empty array
                if ($count === 1 && $batchItems[0]['tool_name'] === 'bulk_update_plugins') {
                    $previewData = $batchItems[0]['preview_data'] ?? null;
                    if (!empty($previewData->plugins) && is_array($previewData->plugins)) {
                        $pluginNames = array_map(function($p) { return $p['name'] ?? $p['slug']; }, $previewData->plugins);
                        $displayParams['target_plugins'] = implode(', ', $pluginNames);
                        if (empty($displayParams['slugs'])) {
                            unset($displayParams['slugs']);
                        }
                    } else {
                        $displayParams['target_plugins'] = 'All pending updates';
                    }
                }

                // Remove preview_data before sending the transient to avoid bloat, since we don't need it later
                foreach ($batchItems as &$item) {
                    unset($item['preview_data']);
                }
                $this->storePendingBatchAction($batchId, $sessionId, $batchItems);

                \wp_send_json_success([
                    'type' => 'tool_confirmation',
                    'action_id' => $batchId,
                    'tool_name' => $count === 1 ? $batchItems[0]['tool_display_name'] : "{$count} actions",
                    'params' => $displayParams,
                    'preview' => implode("\n\n", array_filter(array_column($batchItems, 'preview'))),
                    'tool_call_id' => $batchItems[0]['tool_call_id'],
                    'message' => $message,
                ]);
                return; // Wait for user confirmation
            }

            // No write tools â€” loop continues for next iteration of tool execution
            $history = $this->getSessionHistory($sessionId);
            $response = $this->aiAdapter->continueAfterToolExecution($history, $sessionId);
            $currentLoop++;
        }

        \wp_send_json_success([
            'type' => 'message',
            'content' => $response['content'] ?? 'Action limit reached.',
        ]);
    }

    private function storePendingAction(string $actionId, string $sessionId, string $toolName, array $params, string $toolCallId): void
    {
        $data = [
            'session_id' => $sessionId,
            'tool_name' => $toolName,
            'params' => $params,
            'tool_call_id' => $toolCallId,
            'created_at' => time()
        ];
        \set_transient('lolacore_pending_action_' . $actionId, $data, 2 * HOUR_IN_SECONDS);
    }

    /**
     * Store a batch of write tool calls under a single pending action ID.
     *
     * @param string $batchId    Unique UUID for this batch.
     * @param string $sessionId  Active session.
     * @param array  $batchItems Array of items, each with tool_name, params, tool_call_id, etc.
     */
    private function storePendingBatchAction(string $batchId, string $sessionId, array $batchItems): void
    {
        $data = [
            'session_id' => $sessionId,
            'batch_items' => $batchItems,
            'created_at' => time()
        ];
        \set_transient('lolacore_pending_action_' . $batchId, $data, 2 * HOUR_IN_SECONDS);
    }

    private function getPendingAction(string $actionId): ?array
    {
        $data = \get_transient('lolacore_pending_action_' . $actionId);
        return $data === false ? null : $data;
    }

    private function deletePendingAction(string $actionId): void
    {
        \delete_transient('lolacore_pending_action_' . $actionId);
    }

    private function camelToSnake(string $input): string
    {
        return strtolower((string) preg_replace('/[A-Z]/', '_$0', lcfirst($input)));
    }

    private function verifySecurity(): void
    {
        if (!\check_ajax_referer('lolacore_chat', '_lolacore_nonce', false)) {
            \wp_send_json_error(['message' => ErrorMessages::sessionExpired()], 403);
        }

        if (!\current_user_can('manage_options')) {
            \wp_send_json_error(['message' => ErrorMessages::unauthorized()], 403);
        }
    }

    private function saveMessage(string $sessionId, string $role, string $content, array $toolCalls = [], string $toolCallId = '', string $toolName = '', string $reasoningContent = ''): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_messages';

        $metadata = [];
        if (!empty($toolCalls)) {
            $metadata['tool_calls'] = $toolCalls;
        }
        if (!empty($toolCallId)) {
            $metadata['tool_call_id'] = $toolCallId;
            $metadata['name'] = $toolName;
        }
        if (!empty($reasoningContent)) {
            $metadata['reasoning_content'] = $reasoningContent;
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom LolaCore table; cache invalidated below.
        $wpdb->insert(
            $table,
            [
                'session_id' => $sessionId,
                'role'       => $role,
                'content'    => $content,
                'metadata'   => empty($metadata) ? null : wp_json_encode($metadata),
                'created_at' => \current_time('mysql'),
            ]
        );

        \wp_cache_delete('lolacore_session_history_' . md5($sessionId), 'lolacore');
    }

    private function getSessionHistory(string $sessionId): array
    {
        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_messages';

        $cache_key = 'lolacore_session_history_' . md5($sessionId);
        $cached    = \wp_cache_get($cache_key, 'lolacore');
        if (false !== $cached) {
            $rows = $cached;
        } else {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom LolaCore table; table name is prefixed and trusted, query is prepared, cache stored below.
            $rows = $wpdb->get_results(
                $wpdb->prepare(
                    // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    "SELECT role, content, metadata, created_at FROM {$table} WHERE session_id = %s ORDER BY id ASC",
                    $sessionId
                ),
                ARRAY_A
            ) ?: [];
            \wp_cache_set($cache_key, $rows, 'lolacore', 60);
        }
        
        $history = [];
        foreach ($rows as $row) {
            $msg = [
                'role' => $row['role'],
                'content' => $row['content']
            ];
            if (!empty($row['metadata'])) {
                $meta = json_decode($row['metadata'], true);
                if (isset($meta['tool_calls'])) {
                    $msg['tool_calls'] = $meta['tool_calls'];
                }
                if (isset($meta['tool_call_id'])) {
                    $msg['tool_call_id'] = $meta['tool_call_id'];
                    $msg['name'] = $meta['name'] ?? '';
                }
                if (isset($meta['reasoning_content'])) {
                    $msg['reasoning_content'] = $meta['reasoning_content'];
                }
            }
            $history[] = $msg;
        }
        return $history;
    }

    private function updateSessionLastMessage(string $sessionId): void
    {
        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_sessions';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom LolaCore table; mutating row timestamp.
        $wpdb->update(
            $table,
            ['last_message_at' => \current_time('mysql')],
            ['id' => $sessionId]
        );
    }

    /**
     * Run extractFacts OUT of the user-facing critical path.
     *
     * The synchronous extractFacts call typically adds 1.6–19s of LLM latency
     * to the user-perceived response time (see BASELINE_LATENCY_v1_0.md).
     * Deferring it removes that cost from the critical path while preserving
     * fact extraction (eventual consistency: the next chat turn sees the
     * previous facts).
     *
     * Strategy selection (best → fallback):
     *   1. FPM path: fastcgi_finish_request() — closes the user connection
     *      and keeps the same PHP process running for the extraction. Lowest
     *      overhead, but only available under PHP-FPM SAPI (Linux prod).
     *   2. Remote path: wp_remote_post(..., blocking=false) to an internal
     *      AJAX endpoint authenticated via HMAC. Works on any SAPI (including
     *      cgi-fcgi used by Local by Flywheel on Windows). The extraction
     *      runs in a fresh PHP worker, decoupled from the current request.
     *   3. Synchronous: legacy behavior. Used when LOLACORE_ASYNC_EXTRACT_FACTS
     *      is explicitly disabled.
     */
    private function maybeDeferExtractFacts(string $userMessage, string $content, string $sessionId, array $history): void
    {
        $flagOn = !defined('LOLACORE_ASYNC_EXTRACT_FACTS') || LOLACORE_ASYNC_EXTRACT_FACTS;

        if (!$flagOn) {
            // Escape hatch: force synchronous behavior.
            $this->messageHandler->extractFacts(null, $userMessage, $content, $sessionId, $history);
            return;
        }

        if (function_exists('fastcgi_finish_request')) {
            $this->scheduleFpmDeferredExtraction($userMessage, $content, $sessionId, $history);
            return;
        }

        $this->scheduleRemoteExtraction($userMessage, $content, $sessionId, $history);
    }

    /**
     * Strategy 1: defer via fastcgi_finish_request() (PHP-FPM SAPI).
     *
     * Schedules the extraction as a shutdown action priority 1 to run
     * after the HTTP response is closed via fastcgi_finish_request().
     */
    private function scheduleFpmDeferredExtraction(string $userMessage, string $content, string $sessionId, array $history): void
    {
        $handler = $this->messageHandler;
        \add_action('shutdown', static function () use ($handler, $userMessage, $content, $sessionId, $history) {
            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged -- function_exists checked above.
            fastcgi_finish_request();

            try {
                $handler->extractFacts(null, $userMessage, $content, $sessionId, $history);
            } catch (\Throwable $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Background error log.
                if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[LolaCore] FPM deferred extractFacts failed: ' . $e->getMessage()); }
            }
        }, 1);
    }

    /**
     * Strategy 2: defer via a non-blocking self-call (universal fallback).
     *
     * Stores the params in a short-lived transient (UUID-keyed) and fires
     * a non-blocking POST to admin-ajax.php. A fresh PHP-CGI worker picks
     * up the request, validates the HMAC token, loads the job, and runs
     * extractFacts in isolation from the user-facing request.
     *
     * The user-facing AJAX returns immediately because wp_remote_post with
     * blocking=false only waits for the TCP connect + initial bytes (≈5-50ms
     * on localhost).
     */
    private function scheduleRemoteExtraction(string $userMessage, string $content, string $sessionId, array $history): void
    {
        $jobId = \wp_generate_uuid4();
        $jobKey = self::ASYNC_JOB_PREFIX . $jobId;

        \set_transient($jobKey, [
            'user_message' => $userMessage,
            'content'      => $content,
            'session_id'   => $sessionId,
            'history'      => $history,
        ], 5 * MINUTE_IN_SECONDS);

        $token = $this->generateAsyncJobToken($jobId);

        $url = \admin_url('admin-ajax.php');
        \wp_remote_post($url, [
            'timeout'   => 0.1,
            'blocking'  => false,
            'sslverify' => false,
            'body'      => [
                'action' => 'lolacore_async_extract_facts',
                'job_id' => $jobId,
                'token'  => $token,
            ],
        ]);
    }

    /**
     * Internal AJAX handler that processes a deferred extractFacts job.
     *
     * Authenticated via HMAC token (no nonce, no capability check) because
     * the request comes from PHP itself via wp_remote_post and does not
     * carry the user's cookies. The HMAC secret is derived from wp_salt(),
     * so only code with access to the WordPress install can generate valid
     * tokens.
     */
    public function handleAsyncExtractFacts(): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated via HMAC token below.
        $jobId = isset($_POST['job_id']) ? \sanitize_text_field(\wp_unslash($_POST['job_id'])) : '';
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Authenticated via HMAC token below.
        $token = isset($_POST['token']) ? \sanitize_text_field(\wp_unslash($_POST['token'])) : '';

        if (empty($jobId) || empty($token)) {
            \wp_die('', '', ['response' => 400]);
        }

        $expected = $this->generateAsyncJobToken($jobId);
        if (!\hash_equals($expected, $token)) {
            \wp_die('', '', ['response' => 403]);
        }

        $jobKey = self::ASYNC_JOB_PREFIX . $jobId;
        $job = \get_transient($jobKey);
        if (!is_array($job)) {
            \wp_die('', '', ['response' => 404]);
        }
        \delete_transient($jobKey);

        try {
            $this->messageHandler->extractFacts(
                null,
                (string) ($job['user_message'] ?? ''),
                (string) ($job['content'] ?? ''),
                (string) ($job['session_id'] ?? ''),
                (array) ($job['history'] ?? [])
            );
        } catch (\Throwable $e) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Background error log.
            if (defined('WP_DEBUG') && WP_DEBUG) { error_log('[LolaCore] Remote async extractFacts failed: ' . $e->getMessage()); }
        }

        \wp_die('', '', ['response' => 200]);
    }

    private function generateAsyncJobToken(string $jobId): string
    {
        return \hash_hmac('sha256', $jobId, \wp_salt('auth') . '|lolacore_async_extract');
    }

    private const ASYNC_JOB_PREFIX = 'lolacore_async_extract_job_';
}
