<?php

declare(strict_types=1);

namespace LolaCore\Chat;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Diagnostic exception messages; escaped at REST/chat presentation layer.

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;
use LolaCore\PluginBuilder\Generation\PluginBuilderGenerationCache;
use LolaCore\PluginBuilder\Handler\PluginBuilderHandler;
use LolaCore\BlockBuilder\Handler\BlockBuilderHandler;
use LolaCore\Admin\Widget;
use LolaCore\Chat\OnboardingFlow;

/**
 * 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']);
        \add_action('wp_ajax_lolacore_mark_first_real_greeting', [$this, 'handleMarkFirstRealGreeting']);
        \add_action('wp_ajax_lolacore_get_widget_state', [$this, 'handleGetWidgetState']);

        // 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';

        // Bind the session to its creator so it cannot be read or continued by
        // another admin (see verifySessionOwnership / IDOR hardening).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $wpdb->insert(
            $table,
            [
                'id'           => $sessionId,
                'user_id'      => \get_current_user_id(),
                '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]);
    }

    /**
     * Mark the post-Connectors first real greeting as completed (PRD A.1).
     */
    public function handleMarkFirstRealGreeting(): void
    {
        $this->verifySecurity();
        OnboardingFlow::markFirstRealGreetingDone();
        \wp_send_json_success(['first_real_greeting_done' => true]);
    }

    /**
     * Return fresh widget state for client-side sync (provider transitions without reload).
     */
    public function handleGetWidgetState(): void
    {
        $this->verifySecurity();

        // admin_init does not run on admin-ajax.php; detect provider transitions here too.
        ( new OnboardingFlow() )->trackProviderTransition( true );

        $widget = new Widget();
        \wp_send_json_success($widget->getWidgetState());
    }

    /**
     * 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()]);
        }

        $this->verifySessionOwnership($sessionId);

        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()]);
        }

        $this->verifySessionOwnership($sessionId);

        $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();
        $this->silenceDatabaseErrorOutput();

        // 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(); trusted admin chat input is sanitized via MessageHandler::sanitize() (preserves code snippets). Display XSS is mitigated in lolacore-chat.js.
        $rawMessage = isset($_POST['message']) ? \wp_unslash($_POST['message']) : null;

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

        $this->verifySessionOwnership($sessionId);

        $message = $this->messageHandler->sanitize($rawMessage);
        
        // Internal triggers (onboarding, build-failure recovery) are never stored
        // as user messages — they drive a proactive Lola reply, not user turns.
        $isInternalTrigger = ( $message === '__LOLA_ONBOARDING_INIT__' )
            || ( $message === WPAIClientAdapter::FIRST_REAL_GREETING_SENTINEL )
            || ( strpos( $message, WPAIClientAdapter::BUILDER_FAILURE_SENTINEL ) === 0 );
        if (!$isInternalTrigger) {
            $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, $rawMessage );
        } 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();
        $this->silenceDatabaseErrorOutput();

        // 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()]);
        }

        $this->verifySessionOwnership($sessionId);

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

        $this->deletePendingAction($actionId);
        $executor         = new ActionExecutor();
        $actionRegistry   = $executor->getActionRegistry();
        $artifactResponse = null;
        $hasAsyncBuilderItem = false;

        // 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 (! empty( $item['async'] ) ) {
                    $hasAsyncBuilderItem = true;
                }

                if ($decision === 'cancel') {
                    $resultStr = __( 'The user denied execution of this action.', 'lolacore' );
                    PluginBuilderGenerationCache::delete((string) ($item['preview_cache_key'] ?? ''));
                } else {
                    $executeResponse = null;
                    try {
                        $executeResponse = $executor->execute(
                            $toolName,
                            $this->buildExecutionParams($args, (string) ($item['preview_cache_key'] ?? '')),
                            false
                        );
                        $resultStr = $this->formatToolResultForHistory($toolName, $executeResponse);
                    } 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.
                        /* translators: 1: value */
                        $resultStr = sprintf( __( 'Execution failed: %s', 'lolacore' ), $e->getMessage() );
                    }

                    if ($executeResponse !== null) {
                        $artifactResponse = $this->resolveArtifactAfterExecute(
                            $toolName,
                            $executeResponse,
                            $actionRegistry
                        ) ?? $artifactResponse;
                    }
                }

                if (!empty($item['async'])) {
                    // Async Plugin Builder: the LLM tool_call was already answered at
                    // dispatch ("Generation started…"). Persisting another tool reply
                    // here would orphan it (no preceding assistant tool_call) and 400
                    // every later turn, so record the outcome as an assistant note.
                    $this->saveMessage($sessionId, 'assistant', $resultStr);
                } else {
                    $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.', 'lolacore' );
                PluginBuilderGenerationCache::delete((string) ($pending['preview_cache_key'] ?? ''));
            } else {
                $executeResponse = null;
                try {
                    $executeResponse = $executor->execute(
                        $toolName,
                        $this->buildExecutionParams($args, (string) ($pending['preview_cache_key'] ?? '')),
                        false
                    );
                    $resultStr = $this->formatToolResultForHistory($toolName, $executeResponse);
                } catch (\Throwable $e) {
                    /* translators: 1: value */
                    $resultStr = sprintf( __( 'Execution failed: %s', 'lolacore' ), $e->getMessage() );
                }

                if ($executeResponse !== null) {
                    $artifactResponse = $this->resolveArtifactAfterExecute(
                        $toolName,
                        $executeResponse,
                        $actionRegistry
                    );
                }
            }

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

        if ($decision === 'approve' && $artifactResponse !== null) {
            $this->sendArtifactResponse($artifactResponse);
            // wp_send_json_success() above terminates execution — unreachable.
        }

        // Async Plugin Builder items (createPlugin / updatePluginCode dispatched via
        // background worker) follow a special confirmation path: on success the artifact
        // response above terminates execution. If we reach this point it means the
        // execution returned denied (e.g. preview cache expired). In that case we must
        // NOT call continueAfterToolExecution: the LLM would see a history full of prior
        // failed attempts and immediately re-dispatch the build, causing a double-
        // generation cycle. Instead return a direct error so the user knows to retry.
        if ($hasAsyncBuilderItem && $decision === 'approve') {
            \wp_send_json_success( [
                'type'    => 'message',
                'content' => ErrorMessages::pluginPreviewExpiredRetry(),
            ] );
        }

        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 = [], ?string $triggerMessage = null): void
    {
        $maxLoops = 4;
        $currentLoop = 0;
        $deferredIntentContinued = false;
        $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)) {
                $originalUserMessage = $triggerMessage ?? $userMessage;
                if (
                    !$deferredIntentContinued
                    && $currentLoop === 0
                    && $this->aiAdapter->hasSessionToolset($sessionId)
                    && IntentDeferralDetector::shouldAutoContinue($originalUserMessage, $content)
                ) {
                    if (defined('WP_DEBUG') && WP_DEBUG) {
                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Deferred-intent telemetry.
                        error_log('[LolaCore] Auto-continuing after deferred-intent assistant message.');
                    }
                    $deferredIntentContinued = true;
                    $history = $this->getSessionHistory($sessionId);
                    $response = $this->aiAdapter->continueAfterDeferredIntent($history, $sessionId);
                    $currentLoop++;
                    $userMessage = null;
                    continue;
                }

                if (trim($content) === '') {
                    $content = ErrorMessages::emptyModelResponse();
                }

                $this->maybeCompleteFirstRealGreeting( $triggerMessage ?? $userMessage );

                \wp_send_json_success([
                    'type' => 'message',
                    'content' => $content,
                    'usage' => $response['usage'] ?? []
                ]);
                return;
            }

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

            // --- META-TOOL: Category expansion interceptor ---
            $categoryExpansionCall = null;
            foreach ($toolCalls as $tc) {
                if (($tc['function']['name'] ?? '') === 'requestAdditionalCategories') {
                    $categoryExpansionCall = $tc;
                    break;
                }
            }

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

                if (!is_array($categories)) {
                    $categories = [];
                }

                $expansionResult = $this->aiAdapter->expandSessionCategories($sessionId, $categories);

                if (!($expansionResult['success'] ?? false)) {
                    $toolMessage = $expansionResult['error'] ?? __( 'Category expansion failed.', 'lolacore' );
                } elseif (($expansionResult['added'] ?? []) === []) {
                    $toolMessage = __( 'Those categories are already available in this session.', 'lolacore' );
                } else {
                    $toolMessage = sprintf(
                        /* translators: %s: comma-separated category slugs */
                        __( 'Categories added: %s. Tools from those categories are now available for this session.', 'lolacore' ),
                        implode(', ', $expansionResult['added'])
                    );
                }

                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Expansion telemetry for router improvement (WP_DEBUG only).
                    error_log('[LolaCore Category Expansion] Session: ' . $sessionId . ' | Reason: ' . $reason);
                }

                $this->saveMessage(
                    $sessionId,
                    'tool',
                    $toolMessage,
                    [],
                    $categoryExpansionCall['id'],
                    'requestAdditionalCategories'
                );

                $history = $this->getSessionHistory($sessionId);
                $response = $this->aiAdapter->continueAfterToolExecution($history, $sessionId);
                $currentLoop++;
                continue;
            }

            // --- 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.', 'lolacore' ), [], $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.', 'lolacore' ), [], $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
            $terminalArtifact = null;
            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 {
                    $executeResponse = $executor->execute($executorActionId, $args, !$isWrite);
                    $resultStr       = $this->formatToolResultForHistory($executorActionId, $executeResponse);
                    $resolved        = $this->resolveArtifactAfterExecute(
                        $executorActionId,
                        $executeResponse,
                        $actionRegistry
                    );
                    if ($resolved !== null) {
                        $terminalArtifact = $resolved;
                    }
                } 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.
                    /* translators: 1: value */
                    $resultStr = sprintf( __( 'Error executing tool: %s', 'lolacore' ), $e->getMessage() );
                }

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

            if ($terminalArtifact !== null && empty($writeToolCalls)) {
                $this->sendArtifactResponse($terminalArtifact);
            }

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

                $previewFailed = false;

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

                    try {
                        $preview = $executor->execute($executorActionId, $args, true);

                        // Async Plugin Builder: the handler offloaded generation to a
                        // background worker and returned immediately with a job_id.
                        // Short-circuit the confirmation flow and start polling.
                        $previewResult = isset($preview['result']) ? (array) $preview['result'] : [];
                        if (!empty($previewResult['async_job'])) {
                            $jobId = (string) ($previewResult['job_id'] ?? '');
                            $this->saveMessage(
                                $sessionId,
                                'tool',
                                /* translators: 1: value */
                                sprintf( __( 'Generation started asynchronously. Job ID: %s', 'lolacore' ), $jobId ),
                                [],
                                $tc['id'],
                                $toolName
                            );
                            \wp_send_json_success([
                                'type'    => 'builder_progress',
                                'job_id'  => $jobId,
                                'message' => $previewResult['message'] ?? \__('The engineering team is drafting the plugin…', 'lolacore'),
                            ]);
                            return;
                        }

                        $previewMsg = $preview['message'] ?? '';
                        $itemPreviewFailed = ($preview['mode'] ?? '') === 'denied';

                        $previewData = isset($preview['result']) ? (array) $preview['result'] : [];
                        if ($executorActionId === 'rollback_plugin') {
                            $previewMsg = $this->formatRollbackPluginPreview($previewData);
                        } elseif (!empty($previewData)) {
                            $formattedPreview = array();
                            /* translators: 1: value */
                            $formattedPreview[] = sprintf( __( '**Preview (%s):**', 'lolacore' ), $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);
                        } elseif ($itemPreviewFailed) {
                            /* translators: 1: value */
                            $previewMsg = sprintf( __( '**Preview failed:** %s', 'lolacore' ), $previewMsg );
                        }
                    } catch (\Throwable $e) {
                        /* translators: 1: value */
                        $previewMsg = sprintf( __( '**Preview error:** %s', 'lolacore' ), $e->getMessage() );
                        $preview = [];
                        $itemPreviewFailed = true;
                    }

                    if ($itemPreviewFailed) {
                        $previewFailed = true;
                    }

                    $batchItems[] = [
                        'tool_name' => $executorActionId,
                        'tool_display_name' => $toolName,
                        'params' => $args,
                        'tool_call_id' => $tc['id'],
                        'preview' => $previewMsg,
                        'preview_data' => $preview['result'] ?? null,
                        'preview_cache_key' => $this->extractPreviewCacheKey($preview),
                        'preview_failed' => $itemPreviewFailed,
                        'card_variant' => $executorActionId === 'rollback_plugin' ? 'plugin_rollback' : 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', 'lolacore' ) . ')';
                        }
                    }
                    $previewLines[] = "- {$label}";
                }

                $count = count($batchItems);
                $message = $count === 1
                    /* translators: 1: value */
                    ? sprintf( __( 'Lola requests permission to execute: %s', 'lolacore' ), $batchItems[0]['tool_display_name'] )
                    /* translators: 1: number */
                    : sprintf( __( 'Lola requests permission to execute %d actions', 'lolacore' ), $count );

                $displayParams = $count === 1 ? $batchItems[0]['params'] : ['operations' => implode("\n", $previewLines)];

                if (1 === $count) {
                    $displayParams = apply_filters(
                        'lolacore_confirmation_display_params',
                        $displayParams,
                        $batchItems[0]
                    );
                }
                
                // 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', 'lolacore' );
                    }
                }

                // Remove preview_data before sending the transient to avoid bloat, since we don't need it later
                $rollbackCardData = null;
                if (1 === $count && 'plugin_rollback' === ( $batchItems[0]['card_variant'] ?? '' )) {
                    $rollbackPreview = isset( $batchItems[0]['preview_data'] )
                        ? (array) $batchItems[0]['preview_data']
                        : array();
                    $rollbackCardData = array(
                        'plugin_name'     => (string) ( $rollbackPreview['plugin_name'] ?? ( $batchItems[0]['params']['slug'] ?? '' ) ),
                        'current_version' => (string) ( $rollbackPreview['current_version'] ?? '' ),
                        'target_version'  => (string) ( $rollbackPreview['target_version'] ?? ( $batchItems[0]['params']['target_version'] ?? '' ) ),
                        'was_active'      => ! empty( $rollbackPreview['was_active'] ),
                    );
                }

                foreach ($batchItems as &$item) {
                    unset($item['preview_data']);
                }
                unset( $item );
                $this->storePendingBatchAction($batchId, $sessionId, $batchItems);

                $confirmationPayload = array(
                    'type' => 'tool_confirmation',
                    'action_id' => $batchId,
                    /* translators: 1: number */
                    'tool_name' => $count === 1 ? $batchItems[0]['tool_display_name'] : sprintf( __( '%d actions', 'lolacore' ), $count ),
                    'params' => $displayParams,
                    'preview' => implode("\n\n", array_filter(array_column($batchItems, 'preview'))),
                    'preview_failed' => $previewFailed,
                    'tool_call_id' => $batchItems[0]['tool_call_id'],
                    'message' => $message,
                );

                if (null !== $rollbackCardData) {
                    $confirmationPayload['card_variant']    = 'plugin_rollback';
                    $confirmationPayload['tool_name']       = __( 'Rollback plugin', 'lolacore' );
                    $confirmationPayload['params']            = array();
                    $confirmationPayload['plugin_name']       = $rollbackCardData['plugin_name'];
                    $confirmationPayload['current_version']   = $rollbackCardData['current_version'];
                    $confirmationPayload['target_version']    = $rollbackCardData['target_version'];
                    $confirmationPayload['was_active']        = $rollbackCardData['was_active'];
                    $confirmationPayload['message']           = $this->formatRollbackPluginApprovalMessage( $rollbackCardData );
                    $confirmationPayload['preview']           = '';
                }

                \wp_send_json_success( $confirmationPayload );
                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.', 'lolacore' ),
        ]);
    }

    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
    {
        PendingActionStore::storeBatch($batchId, $sessionId, $batchItems);
    }

    private function getPendingAction(string $actionId): ?array
    {
        return PendingActionStore::get($actionId);
    }

    private function deletePendingAction(string $actionId): void
    {
        PendingActionStore::delete($actionId);
    }

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

    /**
     * @param array<string, mixed> $params
     * @return array<string, mixed>
     */
    private function buildExecutionParams(array $params, string $previewCacheKey): array
    {
        if ($previewCacheKey === '') {
            return $params;
        }

        $params[PluginBuilderGenerationCache::PARAM_KEY] = $previewCacheKey;

        return $params;
    }

    /**
     * @param array<string, mixed> $preview
     */
    private function extractPreviewCacheKey(array $preview): string
    {
        $result = $preview['result'] ?? null;
        if ($result === null) {
            return '';
        }

        if (is_object($result) && isset($result->preview_cache_key)) {
            return (string) $result->preview_cache_key;
        }

        if (is_array($result) && isset($result['preview_cache_key'])) {
            return (string) $result['preview_cache_key'];
        }

        return '';
    }

    /**
     * @param array<string, mixed>|string $executeResponse
     */
    private function formatToolResultForHistory(string $actionId, $executeResponse): string
    {
        if (is_string($executeResponse)) {
            return $executeResponse;
        }

        if (! is_array($executeResponse)) {
            return '';
        }

        if (in_array($actionId, array( 'test_plugin_playground', 'create_plugin', 'update_plugin_code' ), true)) {
            $payload = $this->extractExecuteResultData($executeResponse);
            if (($executeResponse['mode'] ?? '') === 'denied' || empty($payload)) {
                return (string) ( $executeResponse['message'] ?? __( 'Action denied.', 'lolacore' ) );
            }

            return PluginBuilderHandler::slimToolResultForHistory($actionId, $payload);
        }

        if (in_array($actionId, array( 'test_block', 'create_block', 'update_block_code' ), true)) {
            $payload = $this->extractExecuteResultData($executeResponse);
            if (($executeResponse['mode'] ?? '') === 'denied' || empty($payload)) {
                return (string) ( $executeResponse['message'] ?? __( 'Action denied.', 'lolacore' ) );
            }

            return BlockBuilderHandler::slimToolResultForHistory($actionId, $payload);
        }

        return \wp_json_encode($executeResponse) ?: '';
    }

    /**
     * @param array<string, mixed> $executeResponse
     * @return array<string, mixed>
     */
    private function extractExecuteResultData(array $executeResponse): array
    {
        if (($executeResponse['mode'] ?? '') === 'denied') {
            return array();
        }

        $result = $executeResponse['result'] ?? array();
        if ($result instanceof \stdClass) {
            return (array) $result;
        }

        return is_array($result) ? $result : array();
    }

    /**
     * @param array<string, array{requires_confirmation: bool, type: string, presentation?: string, artifact?: string}> $actionRegistry
     * @param array<string, mixed>                                                                                      $executeResponse
     * @return array{artifact: string, message: string, data: array<string, mixed>}|null
     */
    private function resolveArtifactAfterExecute(
        string $actionId,
        array $executeResponse,
        array $actionRegistry
    ): ?array {
        $meta = $actionRegistry[ $actionId ] ?? array();
        $presentation = (string) ( $meta['presentation'] ?? 'narrative' );
        $artifactType = (string) ( $meta['artifact'] ?? '' );

        if ($artifactType === '') {
            return null;
        }

        $allowed = array( 'terminal_artifact', 'artifact_after_write' );
        if (! in_array($presentation, $allowed, true)) {
            return null;
        }

        $payload = $this->extractExecuteResultData($executeResponse);
        if (($executeResponse['mode'] ?? '') === 'denied' || empty($payload)) {
            return null;
        }

        if ($presentation === 'artifact_after_write' && empty($payload['post_id'])) {
            return null;
        }

        return array(
            'artifact' => $artifactType,
            'message'  => self::artifactIntroMessageForType($artifactType),
            'data'     => self::artifactDataForType($artifactType, $payload),
        );
    }

    private static function artifactIntroMessageForType( string $artifactType ): string {
        if (str_starts_with($artifactType, 'block_')) {
            return BlockBuilderHandler::artifactIntroMessage($artifactType);
        }

        return PluginBuilderHandler::artifactIntroMessage($artifactType);
    }

    /**
     * @param array<string, mixed> $payload
     * @return array<string, mixed>
     */
    private static function artifactDataForType( string $artifactType, array $payload ): array {
        if (str_starts_with($artifactType, 'block_')) {
            return BlockBuilderHandler::artifactDataForUi($artifactType, $payload);
        }

        return PluginBuilderHandler::artifactDataForUi($artifactType, $payload);
    }

    /**
     * @param array{artifact: string, message: string, data: array<string, mixed>} $artifactResponse
     */
    private function sendArtifactResponse(array $artifactResponse): void
    {
        \wp_send_json_success(
            array(
                'type'     => 'artifact',
                'artifact' => $artifactResponse['artifact'],
                'message'  => $artifactResponse['message'],
                'data'     => $artifactResponse['data'],
            )
        );
    }

    /**
     * Mark first real greeting done after onboarding init or post-Connectors greeting.
     */
    private function maybeCompleteFirstRealGreeting( ?string $message ): void {
        if ( $message === '__LOLA_ONBOARDING_INIT__' || $message === WPAIClientAdapter::FIRST_REAL_GREETING_SENTINEL ) {
            OnboardingFlow::markFirstRealGreetingDone();
        }
    }

    /**
     * Keep wpdb failures out of admin-ajax output so JSON responses stay parseable.
     */
    private function silenceDatabaseErrorOutput(): void
    {
        global $wpdb;

        if (!is_object($wpdb)) {
            return;
        }

        $wpdb->suppress_errors(true);
        $wpdb->hide_errors();
    }

    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);
        }
    }

    /**
     * Reject requests that target a session owned by another user (IDOR guard).
     *
     * The session_id is supplied by the client, so capability alone is not
     * enough on multi-admin sites: one admin must not read or continue another
     * admin's session by guessing its id. Sessions created before the user_id
     * column existed (or never bound) have a NULL owner and are left accessible
     * so the DB upgrade never locks anyone out of their own history.
     */
    private function verifySessionOwnership(string $sessionId): void
    {
        if ($sessionId === '') {
            return;
        }

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

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom LolaCore table; table name is prefix + fixed suffix.
        $ownerId = $wpdb->get_var($wpdb->prepare("SELECT user_id FROM {$table} WHERE id = %s", $sessionId));

        // NULL = unknown session (not yet created) or legacy row without an owner.
        if ($ownerId === null) {
            return;
        }

        if ((int) $ownerId !== \get_current_user_id()) {
            \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');
    }

    /**
     * Human-readable rollback preview for batch storage (no technical dump).
     *
     * @param array<string, mixed> $previewData
     */
    private function formatRollbackPluginPreview(array $previewData): string
    {
        $plugin_name = (string) ( $previewData['plugin_name'] ?? '' );
        $current     = (string) ( $previewData['current_version'] ?? '' );
        $target      = (string) ( $previewData['target_version'] ?? '' );
        $was_active  = ! empty( $previewData['was_active'] );

        $lines = array();
        if ( '' !== $plugin_name ) {
            /* translators: 1: plugin name */
            $lines[] = sprintf( __( '**Plugin:** %s', 'lolacore' ), $plugin_name );
        }
        if ( '' !== $current && '' !== $target ) {
            /* translators: 1: current version, 2: target version */
            $lines[] = sprintf( __( '**Version:** %1$s → %2$s', 'lolacore' ), $current, $target );
        }
        if ( $was_active ) {
            $lines[] = __( '**Status:** Active — I\'ll turn it off briefly during the swap, then turn it back on.', 'lolacore' );
        }

        return implode( "\n", $lines );
    }

    /**
     * Chat intro above the rollback confirmation card.
     *
     * @param array<string, mixed> $data
     */
    private function formatRollbackPluginApprovalMessage(array $data): string
    {
        $plugin_name = (string) ( $data['plugin_name'] ?? '' );
        $current     = (string) ( $data['current_version'] ?? '' );
        $target      = (string) ( $data['target_version'] ?? '' );
        $was_active  = ! empty( $data['was_active'] );

        if ( '' !== $plugin_name && '' !== $current && '' !== $target ) {
            $message = sprintf(
                /* translators: 1: plugin name, 2: current version, 3: target version */
                __( 'I\'ll roll %1$s back from %2$s to %3$s — the same plugin from WordPress.org, just an earlier release.', 'lolacore' ),
                $plugin_name,
                $current,
                $target
            );
        } else {
            $message = __( 'I\'ll roll this plugin back to an earlier release from WordPress.org.', 'lolacore' );
        }

        if ( $was_active ) {
            $message .= ' ' . __( 'It\'s active now, so I\'ll deactivate it briefly and turn it back on when I\'m done.', 'lolacore' );
        }

        return $message;
    }

    private const ASYNC_JOB_PREFIX = 'lolacore_async_extract_job_';
}

