<?php

declare(strict_types=1);

namespace LolaCore\AI;

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

use LolaCore\Memory\ContextBuilder;
use LolaCore\Scanner\SiteScanner;
use LolaCore\Chat\SystemPromptBuilder;
use LolaCore\Actions\AbilitiesRegistry;
use LolaCore\Actions\ToolParameterSchema;
use LolaCore\I18n\ErrorMessages;
use LolaCore\LLM\Providers\DeepSeek\DeepSeekModelSupport;
use LolaCore\LLM\SelectedModelResolver;
use LolaCore\PluginBuilder\Async\JobManager;
use LolaCore\PluginBuilder\Recovery\RecoveryBriefBuilder;
use WordPress\AiClient\AiClient;
use WordPress\AiClient\Common\Exception\AiClientException;
use WordPress\AiClient\Providers\Http\DTO\RequestOptions;

/**
 * WPAIClientAdapter
 *
 * Orchestrates chat requests by routing intents, pruning tools,
 * and communicating with the native WP 7.0 AI Client.
 */
class WPAIClientAdapter
{
    /**
     * Gemini chokes on large function-declaration payloads (empty candidates).
     * Keep a conservative cap and fall back to discover-first tooling.
     */
    private const GOOGLE_MAX_FUNCTION_DECLARATIONS = 32;

    /** Max category slugs per requestAdditionalCategories call. */
    private const MAX_CATEGORIES_PER_EXPANSION = 2;

    /** Max requestAdditionalCategories calls per chat session. */
    private const MAX_EXPANSIONS_PER_SESSION = 4;

    /** Category slugs that cannot be added via session expansion. */
    private const RESERVED_EXPANSION_CATEGORIES = ['global', 'meta', 'memory', 'uncategorized'];

    /** Internal continuation sentinel — routeIntent Layer -0.5 preserves session toolset. */
    private const TOOL_RESULTS_CONTINUE = '__TOOL_RESULTS_CONTINUE__';

    /** Internal sentinel when the model deferred reads/actions in text only. */
    public const DEFERRED_INTENT_CONTINUE = '__DEFERRED_INTENT_CONTINUE__';

    /**
     * Internal trigger sent by the chat frontend when a background plugin build
     * fails. Carries the failed job id as a suffix (e.g. "__LOLA_BUILDER_FAILED__<uuid>")
     * so Lola can produce a warm, in-language recovery message instead of going silent.
     */
    public const BUILDER_FAILURE_SENTINEL = '__LOLA_BUILDER_FAILED__';

    /**
     * Internal trigger after the user configures Connectors post-onboarding (PRD A.1).
     */
    public const FIRST_REAL_GREETING_SENTINEL = '__LOLA_FIRST_REAL_GREETING__';

    private ContextBuilder $contextBuilder;
    private SiteScanner $scanner;
    private SystemPromptBuilder $systemPromptBuilder;
    private AbilitiesRegistry $abilitiesRegistry;

    public function __construct(
        ContextBuilder $contextBuilder,
        SiteScanner $scanner,
        SystemPromptBuilder $systemPromptBuilder,
        AbilitiesRegistry $abilitiesRegistry
    ) {
        $this->contextBuilder = $contextBuilder;
        $this->scanner = $scanner;
        $this->systemPromptBuilder = $systemPromptBuilder;
        $this->abilitiesRegistry = $abilitiesRegistry;
    }

    /**
     * Sends a message to the LLM via wp_ai_client_prompt().
     *
     * @param string $message      User message
     * @param array  $history      Conversation history
     * @param string $sessionId    Active session ID
     * @return array{content: string, tool_calls: array, usage: array}
     */
    public function chat(string $message, array $history, string $sessionId): array
    {
        $intentData = $this->routeIntent($message, $history, $sessionId);

        // Onboarding: use specialized prompt that produces a proactive first insight
        $isOnboarding = ( $message === '__LOLA_ONBOARDING_INIT__' );
        $isFirstRealGreeting = ( $message === self::FIRST_REAL_GREETING_SENTINEL );
        $isBuilderFailure = ( strpos( $message, self::BUILDER_FAILURE_SENTINEL ) === 0 );
        if ( $isOnboarding ) {
            $scanData = $this->scanner->getLastScan();
            $scanJson = $scanData ? \wp_json_encode( $scanData ) : '{}';
            $systemPrompt = $this->systemPromptBuilder->buildOnboarding( $scanJson );
            // Replace the internal message with a clean activation trigger
            $message = 'Introduce yourself to the user for the first time.';
        } elseif ( $isFirstRealGreeting ) {
            $scanData = $this->scanner->getLastScan();
            $scanJson = $scanData ? \wp_json_encode( $scanData ) : '{}';
            $systemPrompt = $this->systemPromptBuilder->buildFirstRealGreeting( $scanJson );
            $message = 'Generate your first real greeting for this user.';
            $intentData['abilities'] = array();
            $intentData['sops']      = array();
        } elseif ($isBuilderFailure) {
            // A background build failed. Produce a conversational recovery message
            // with no tools attached, so Lola simply talks (the retry is driven
            // deterministically by the button on the build card).
            $jobId = trim(substr($message, strlen(self::BUILDER_FAILURE_SENTINEL)));
            $message = $this->buildBuilderFailureContext($jobId);
            $intentData['abilities'] = array();
            $intentData['sops']      = array();
            $systemPrompt = $this->buildSystemPrompt('', $message, false, $sessionId);
        } else {
            $systemPrompt = $this->buildSystemPrompt($intentData['sops'], $message, !empty($intentData['abilities']), $sessionId);
        }

        $builder = \wp_ai_client_prompt($systemPrompt);

        // Append conversation history
        foreach ($history as $msg) {
            if ($msg['role'] === 'user') {
                $builder->with_user_message($msg['content']);
            } elseif ($msg['role'] === 'assistant') {
                // Determine if it has tool calls
                if (!empty($msg['tool_calls'])) {
                    $builder->with_model_message($msg['content'] ?? ''); // Not yet fully supported by WP AI Client abstraction seamlessly without custom Message objects, but we handle it via lower levels if needed. Wait, we can just pass the string for now.
                } else {
                    $builder->with_model_message($msg['content'] ?? '');
                }
            } elseif ($msg['role'] === 'tool') {
                // Actually, WP AI Client Prompt Builder doesn't natively expose `with_tool_result` elegantly in all builds.
                // But it does support chat history array via `with_messages()`.
                // For simplicity in this v1 adapter, we will use `ask()` which appends a user message to the builder.
            }
        }

        // To support full tool history and multi-turn properly with wp_ai_client_prompt, we actually need to build raw messages if it doesn't support with_tool_result.
        // Wait, WP AI Client Prompt Builder is mostly for simple prompts. For advanced tool calling, we might need to access the model directly.
        // Let's use the builder's underlying `execute()` or access the model.
        // Actually, Sprint plan says: "Primera iteracion de wp_ai_client_prompt() en flujo de chat completo".
        // Let's use the underlying model if builder is too simple, or just use builder if it has `with_messages()`.
        
        // For the sake of the 1.0.0 adapter, let's use the standard `AiClient` model interface.
        $modelConfig = $this->getSelectedModel();
        $providerId = $modelConfig['provider'];
        $modelId = $modelConfig['model'];

        try {
            $model = AiClient::defaultRegistry()->getProviderModel($providerId, $modelId);
        } catch (\Exception $e) {
            throw new \RuntimeException(esc_html(ErrorMessages::modelLoadFailed($e->getMessage())));
        }

        $intentData = $this->applyProviderToolLimits($intentData, $providerId, $sessionId);

        // Build messages manually for full control over tool_calls and tool_results
        $messages = $this->buildRawMessages($history, $message);

        // Apply tools
        if (!empty($intentData['abilities'])) {
            $model->getConfig()->setFunctionDeclarations($intentData['abilities']);
            self::debugLog('[LolaCore Router] Tools sent: ' . count($intentData['abilities']));
        } else {
            self::debugLog('[LolaCore Router] WARNING: no tools sent (intent abilities empty).');
        }
        
        $model->getConfig()->setSystemInstruction($systemPrompt);

        $model->setRequestOptions($this->buildRequestOptions());

        // Fix outgoing HTTP request body for provider compatibility.
        // Always applied because:
        // 1. Tool schemas: PHP json_encode turns empty arrays [] instead of
        //    objects {} for 'properties', which OpenAI/DeepSeek APIs reject.
        // 2. DeepSeek thinking mode: reasoning_content must be passed back
        //    in assistant messages, but the SDK strips it.
        $reasoningMap = $this->buildReasoningMap($history);
        $disableThinking = $this->shouldDisableThinking($modelConfig);
        $filterCallback = function ($args) use ($reasoningMap, $disableThinking) {
            return $this->fixOutgoingRequestBody($args, $reasoningMap, $disableThinking);
        };
        \add_filter('http_request_args', $filterCallback, 10, 1);

        try {
            $result = $model->generateTextResult($messages);

            $candidate = $result->getCandidates()[0];
            $responseMessage = $candidate->getMessage();

            $content = '';
            $reasoningContent = '';
            $toolCalls = [];

            foreach ($responseMessage->getParts() as $part) {
                if ($part->getType()->isText()) {
                    if ($part->getChannel()->isThought()) {
                        $reasoningContent .= $part->getText();
                        continue;
                    }
                    $content .= $part->getText() . "\n";
                } elseif ($part->getType()->isFunctionCall()) {
                    $call = $part->getFunctionCall();
                    $toolCalls[] = [
                        'id' => $this->ensureToolCallId($call->getId()),
                        'function' => [
                            'name' => $call->getName(),
                            'arguments' => $this->encodeFunctionCallArguments($call->getArgs())
                        ]
                    ];
                }
            }

            $usageInfo = $result->getTokenUsage();

            return $this->finalizeChatResponse(trim($content), $reasoningContent, $toolCalls, $usageInfo);
        } catch (AiClientException $e) {
            throw new \RuntimeException(esc_html(ErrorMessages::formatProviderError($e->getMessage())));
        } finally {
            \remove_filter('http_request_args', $filterCallback, 10);
        }
    }

    /**
     * Build the internal instruction that drives Lola's recovery message after a
     * background plugin build fails.
     *
     * Reads the failed job's transient for the original request and the failure
     * reason, then frames an instruction (never shown to the user) so Lola speaks
     * in the user's language, stays warm and brief, and points to exactly one next
     * step — either the on-card "Try again" button (retries left) or a different
     * approach (retries exhausted). The raw technical reason is given as private
     * context only; Lola is told not to repeat jargon back to the user.
     */
    private function buildBuilderFailureContext(string $jobId): string
    {
        $state   = array();
        $request = '';

        if ($jobId !== '') {
            $loaded = (new JobManager())->getState($jobId);
            if (is_array($loaded)) {
                $state = $loaded;
            }
        }

        $params = (array) ( $state['params'] ?? array() );
        $request = isset($params['description'])
            ? trim((string) $params['description'])
            : (isset($params['instruction']) ? trim((string) $params['instruction']) : '');

        $brief = RecoveryBriefBuilder::build($state);
        $briefJson = \wp_json_encode($brief);
        if (! is_string($briefJson)) {
            $briefJson = '{}';
        }

        $lines = array(
            'SYSTEM EVENT (not a user message): a plugin build that was running in the background just failed before it could finish.',
            'Recovery brief (JSON, authoritative — do not invent architecture advice beyond this brief):',
            $briefJson,
            '',
            'INSTRUCTION: Rephrase summary_en and each label_en in allowed_next_steps warmly in the user\'s language.',
            'Do NOT add steps beyond allowed_next_steps. Do NOT suggest anything listed in forbidden_suggestions.',
            'Do NOT recommend splitting the plugin into separate PHP, JavaScript, and CSS files or jobs.',
            'Keep the reply short. Do not apologize more than once, do not blame the user, and do not use technical jargon.',
        );

        if ($request !== '') {
            $lines[] = 'What the user originally asked you to build: "' . $request . '".';
        }

        $reason = is_string($state['error'] ?? null) ? trim((string) $state['error']) : '';
        if ($reason !== '') {
            $lines[] = 'Internal failure reason (context only — do NOT quote this wording back to the user): "' . $reason . '".';
        }

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

    /**
     * Continues conversation after tool execution results have been added to history.
     *
     * After the user confirms a write action (or read tools auto-execute),
     * the tool results are stored in the DB as 'tool' role messages.
     * We need to send these back to the LLM so it can summarize the outcome.
     *
     * A synthetic user message is injected because most LLM providers
     * (DeepSeek, OpenAI) reject conversations ending with bare 'tool'
     * messages â€” they require a user message to trigger a new response.
     */
    public function continueAfterToolExecution(array $history, string $sessionId): array
    {
        // Layer -0.5 in routeIntent preserves routed + expanded session categories.
        return $this->chat(self::TOOL_RESULTS_CONTINUE, $history, $sessionId);
    }

    /**
     * Re-prompts the model when it announced a lookup in text but emitted no tool calls.
     *
     * @param array<int, array<string, mixed>> $history
     * @return array{content: string, tool_calls: array, usage: array}
     */
    public function continueAfterDeferredIntent(array $history, string $sessionId): array
    {
        return $this->chat(self::DEFERRED_INTENT_CONTINUE, $history, $sessionId);
    }

    public function hasSessionToolset(string $sessionId): bool
    {
        return $this->getSessionToolsetCategories($sessionId) !== [];
    }

    /**
     * Adds tool categories to the session toolset (MCP-style progressive escalation).
     *
     * Called by the Controller when Lola invokes requestAdditionalCategories.
     *
     * @param string        $sessionId  Active chat session ID.
     * @param array<int, string> $categories Category slugs to add (1-2 max).
     * @return array{success: bool, error?: string, added?: list<string>, session_categories?: list<string>}
     */
    public function expandSessionCategories(string $sessionId, array $categories): array
    {
        $allAbilities = $this->discoverAvailableAbilities();
        $categoryMap = $this->buildCategoryToolMap($allAbilities);
        $validCategories = array_keys($categoryMap);

        $categories = array_values(array_unique(array_filter(
            array_map('strval', $categories),
            static fn(string $category): bool => $category !== ''
        )));

        if ($categories === []) {
            return ['success' => false, 'error' => 'No categories provided.'];
        }

        if (count($categories) > self::MAX_CATEGORIES_PER_EXPANSION) {
            return [
                'success' => false,
                'error' => 'Too many categories (max ' . self::MAX_CATEGORIES_PER_EXPANSION . ').',
            ];
        }

        foreach ($categories as $category) {
            if (in_array($category, self::RESERVED_EXPANSION_CATEGORIES, true)) {
                return ['success' => false, 'error' => "Category '{$category}' cannot be expanded."];
            }
            if (!in_array($category, $validCategories, true)) {
                return ['success' => false, 'error' => "Unknown category '{$category}'."];
            }
        }

        $countKey = 'lolacore_cat_expansion_count_' . md5($sessionId);
        $expansionCount = (int) \get_transient($countKey);
        if ($expansionCount >= self::MAX_EXPANSIONS_PER_SESSION) {
            return ['success' => false, 'error' => 'Category expansion limit reached for this session.'];
        }

        $expanded = $this->getExpandedCategories($sessionId);
        $added = [];
        foreach ($categories as $category) {
            if (!in_array($category, $expanded, true)) {
                $expanded[] = $category;
                $added[] = $category;
            }
        }

        if ($added !== []) {
            $hash = md5($sessionId);
            \set_transient('lolacore_expanded_cats_' . $hash, $expanded, 12 * HOUR_IN_SECONDS);
            \set_transient($countKey, $expansionCount + 1, 12 * HOUR_IN_SECONDS);
        }

        return [
            'success' => true,
            'added' => $added,
            'session_categories' => $this->getSessionToolsetCategories($sessionId),
        ];
    }

    /**
     * Re-processes a message with the FULL toolset, bypassing the intent router.
     *
     * Called exclusively by the escalation interceptor when Lola invokes
     * the requestFullToolset meta-tool. This method skips the 3-layer
     * router and injects every available tool so Lola can find the one
     * she was missing.
     *
     * @param string $message   The original user message to re-process.
     * @param array  $history   Full conversation history (including escalation messages).
     * @param string $sessionId Active session ID.
     * @return array{content: string, tool_calls: array, usage: array}
     */
    public function chatWithFullToolset(string $message, array $history, string $sessionId): array
    {
        $allAbilities = $this->discoverAvailableAbilities();
        $categoryMap = $this->buildCategoryToolMap($allAbilities);
        $intentData = $this->buildFullToolset($categoryMap, $allAbilities);

        self::debugLog('[LolaCore Router] Escalation: full toolset (' . count($intentData['abilities']) . ' tools).');

        $systemPrompt = $this->buildSystemPrompt($intentData['sops'], $message, !empty($intentData['abilities']), $sessionId);

        $modelConfig = $this->getSelectedModel();
        $providerId = $modelConfig['provider'];
        $modelId = $modelConfig['model'];

        try {
            $model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel($providerId, $modelId);
        } catch (\Exception $e) {
            throw new \RuntimeException(esc_html(ErrorMessages::modelLoadFailed($e->getMessage())));
        }

        $intentData = $this->applyProviderToolLimits($intentData, $providerId, $sessionId);

        $messages = $this->buildRawMessages($history, $message);

        if (!empty($intentData['abilities'])) {
            $model->getConfig()->setFunctionDeclarations($intentData['abilities']);
        }

        $model->getConfig()->setSystemInstruction($systemPrompt);
        $model->setRequestOptions($this->buildRequestOptions());

        $reasoningMap = $this->buildReasoningMap($history);
        $disableThinking = $this->shouldDisableThinking($modelConfig);
        $filterCallback = function ($args) use ($reasoningMap, $disableThinking) {
            return $this->fixOutgoingRequestBody($args, $reasoningMap, $disableThinking);
        };
        \add_filter('http_request_args', $filterCallback, 10, 1);

        try {
            $result = $model->generateTextResult($messages);

            $candidate = $result->getCandidates()[0];
            $responseMessage = $candidate->getMessage();

            $content = '';
            $reasoningContent = '';
            $toolCalls = [];

            foreach ($responseMessage->getParts() as $part) {
                if ($part->getType()->isText()) {
                    if ($part->getChannel()->isThought()) {
                        $reasoningContent .= $part->getText();
                        continue;
                    }
                    $content .= $part->getText() . "\n";
                } elseif ($part->getType()->isFunctionCall()) {
                    $call = $part->getFunctionCall();
                    $toolCalls[] = [
                        'id' => $this->ensureToolCallId($call->getId()),
                        'function' => [
                            'name' => $call->getName(),
                            'arguments' => $this->encodeFunctionCallArguments($call->getArgs())
                        ]
                    ];
                }
            }

            $usageInfo = $result->getTokenUsage();

            return $this->finalizeChatResponse(trim($content), $reasoningContent, $toolCalls, $usageInfo);
        } catch (\WordPress\AiClient\Common\Exception\AiClientException $e) {
            throw new \RuntimeException(esc_html(ErrorMessages::formatEscalationProviderError($e->getMessage())));
        } finally {
            \remove_filter('http_request_args', $filterCallback, 10);
        }
    }

    /**
     * @param array<int, array<string, mixed>> $toolCalls
     * @param \WordPress\AiClient\Common\DTO\TokenUsage|null $usageInfo
     * @return array{content: string, reasoning_content: string, tool_calls: array, usage: array}
     */
    private function finalizeChatResponse(
        string $content,
        string $reasoningContent,
        array $toolCalls,
        $usageInfo
    ): array {
        if ($content === '' && $toolCalls === array() && trim($reasoningContent) !== '') {
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnose empty visible replies.
                error_log('[LolaCore] Model returned reasoning-only text with no tool calls.');
            }
            $content = ErrorMessages::emptyModelResponse();
        } elseif ($content === '' && $toolCalls === array()) {
            $content = ErrorMessages::emptyModelResponse();
        }

        return array(
            'content'           => $content,
            'reasoning_content' => $reasoningContent,
            'tool_calls'        => $toolCalls,
            'usage'             => array(
                'input_tokens'  => $usageInfo ? $usageInfo->getPromptTokens() : 0,
                'output_tokens' => $usageInfo ? $usageInfo->getCompletionTokens() : 0,
            ),
        );
    }

    /**
     * Generates session summary.
     */
    public function generateSessionSummary(array $history): string
    {
        $formattedHistory = '';
        foreach ($history as $msg) {
            if ($msg['role'] === 'tool') continue;
            $role = strtoupper($msg['role']);
            $content = $msg['content'] ?? '';
            if (empty(trim($content))) continue;
            $formattedHistory .= "{$role}: {$content}\n\n";
        }

        if (empty(trim($formattedHistory))) {
            return '';
        }

        $systemPrompt = $this->systemPromptBuilder->buildSessionSummary();
        
        $modelConfig = $this->getSelectedModel();
        try {
            $model = AiClient::defaultRegistry()->getProviderModel($modelConfig['provider'], $modelConfig['model']);
        } catch (\Exception $e) {
            return '';
        }
        
        $model->getConfig()->setSystemInstruction($systemPrompt);
        $model->setRequestOptions($this->buildRequestOptions());

        $messages = [
            new \WordPress\AiClient\Messages\DTO\Message(
                \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(),
                [new \WordPress\AiClient\Messages\DTO\MessagePart("Summarize this conversation:\n\n" . $formattedHistory)]
            )
        ];

        try {
            $result = $model->generateTextResult($messages);

            $candidate = $result->getCandidates()[0];
            $content = '';
            foreach ($candidate->getMessage()->getParts() as $part) {
                // Reasoning models emit their thinking as a text part on the
                // thought channel. It is shown to the user verbatim, so it
                // never belongs in the summary.
                if ($part->getType()->isText() && !$part->getChannel()->isThought()) {
                    $content .= $part->getText();
                }
            }
            return trim($content);
        } catch (\Exception $e) {
            return '';
        }
    }

    /**
     * Builds RequestOptions with extended timeouts for LLM calls.
     *
     * LLM responses routinely exceed WordPress's default 5s HTTP timeout.
     * The SDK supports per-request transport options via setRequestOptions(),
     * which propagate down to wp_safe_remote_request() through
     * WP_AI_Client_HTTP_Client::sendRequestWithOptions().
     */
    private function buildRequestOptions(): RequestOptions
    {
        $options = new RequestOptions();
        $options->setTimeout(120.0);
        $options->setConnectTimeout(10.0);
        return $options;
    }

    /**
     * Intent router.
     */
    /**
     * Intent router: adaptive 3-layer architecture.
     *
     * Layer 0: Contextual Cache â€” handles yes/no follow-ups without reclassification.
     * Layer 1: Deterministic Keyword Matching â€” fast, zero-cost exact/stem matching.
     * Layer 2: Semantic Classifier â€” lightweight LLM micro-agent for ambiguous intents.
     * Fallback: Inject all tools if classifier fails or returns empty.
     */
    private function routeIntent(string $message, array $history, string $sessionId): array
    {
        $messageLower = strtolower(trim($message));

        if ($this->isGreeting($messageLower)) {
            return [
                'abilities' => [],
                'sops' => "SOP: The user is greeting you. Reply with a single brief line and ask what they need. Do not run audits, dump data, or list capabilities."
            ];
        }

        $allAbilities = $this->discoverAvailableAbilities();
        $categoryMap = $this->buildCategoryToolMap($allAbilities);

        // Layer -0.5: Tool continuation — preserve routed + expanded session toolset.
        if ($message === self::TOOL_RESULTS_CONTINUE || $message === self::DEFERRED_INTENT_CONTINUE) {
            $sessionCategories = $this->getSessionToolsetCategories($sessionId);
            if ($sessionCategories !== []) {
                $label = $message === self::DEFERRED_INTENT_CONTINUE ? 'deferred-intent' : 'continuation';
                self::debugLog('[LolaCore Router] Layer -0.5: ' . $label . ' -> ' . self::summarizeListForLog($sessionCategories));
                return $this->resolveCategoriesToIntentData($sessionCategories, $categoryMap, $allAbilities);
            }
        }

        // Layer 0: Contextual Cache
        if ($this->isContextualConfirmation($messageLower)) {
            $matchedCategories = $this->getSessionToolsetCategories($sessionId);
            if (!empty($matchedCategories)) {
                self::debugLog('[LolaCore Router] Layer 0: contextual cache -> ' . self::summarizeListForLog($matchedCategories));
                return $this->resolveCategoriesToIntentData($matchedCategories, $categoryMap, $allAbilities);
            }
        }

        // Layer 0.5: Meta-intent detection (BEFORE keyword matching).
        // Inspects ONLY the current user message — using the context window would
        // cause false positives on subsequent turns once a meta phrase appears
        // in history (e.g. asking "qué puedes hacer" then asking a normal
        // question would re-trigger meta-intent forever).
        if ($this->isMetaToolQuery($messageLower)) {
            self::debugLog('[LolaCore Router] Layer 0.5: meta-intent -> full toolset.');
            $this->cacheMatchedCategories($sessionId, array_keys($categoryMap));
            return $this->buildFullToolset($categoryMap, $allAbilities, true);
        }

        // Layer 0.6: Holistic monitoring query detection.
        // Same scoping rule as Layer 0.5 — current message only, no history,
        // to prevent cross-turn pollution of intent detection.
        if ($this->isHolisticMonitoringQuery($messageLower)) {
            self::debugLog('[LolaCore Router] Layer 0.6: holistic monitoring -> full toolset.');
            $this->cacheMatchedCategories($sessionId, array_keys($categoryMap));
            return $this->buildFullToolset($categoryMap, $allAbilities, false, true);
        }

        // Layer 1: Deterministic Keyword Matching — uses the context window
        // (history + current) deliberately, because keyword matching benefits
        // from disambiguating short follow-ups against prior context.
        $contextText = $this->buildContextWindow($history, $messageLower);
        $matchedCategories = $this->matchByKeywords($contextText, $categoryMap);

        if (!empty($matchedCategories)) {
            self::debugLog('[LolaCore Router] Layer 1: keywords -> ' . self::summarizeListForLog($matchedCategories));
            $this->cacheMatchedCategories($sessionId, $matchedCategories);
            $toolsetCategories = $this->getSessionToolsetCategories($sessionId);
            return $this->resolveCategoriesToIntentData($toolsetCategories, $categoryMap, $allAbilities);
        }

        // Google free tier has tight RPM limits; skip the classifier LLM call and use
        // discover-first tooling instead of burning an extra request per message.
        if ($this->getActiveProviderId() === 'google') {
            self::debugLog('[LolaCore Router] Google provider: discover-first fallback (classifier skipped).');
            $this->cacheMatchedCategories($sessionId, ['global', 'meta', 'memory']);
            return $this->buildGoogleDiscoverFirstIntent($categoryMap, $allAbilities);
        }

        // Layer 2: Semantic Classifier (micro-agent LLM call)
        try {
            $classifiedCategories = $this->classifyIntent($messageLower, $categoryMap);

            if (!empty($classifiedCategories)) {
                self::debugLog('[LolaCore Router] Layer 2: classifier -> ' . self::summarizeListForLog($classifiedCategories));
                $this->cacheMatchedCategories($sessionId, $classifiedCategories);
                $toolsetCategories = $this->getSessionToolsetCategories($sessionId);
                return $this->resolveCategoriesToIntentData($toolsetCategories, $categoryMap, $allAbilities);
            }
        } catch (\Throwable $e) {
            // Classifier failed â€” degrade silently to full toolset.
            self::debugLog('[LolaCore Router] Layer 2 failed: ' . $e->getMessage() . ' -> fallback to all tools.');
        }

        // Fallback: inject all tools
        self::debugLog('[LolaCore Router] Fallback: all ' . count($allAbilities) . ' tools.');
        $this->cacheMatchedCategories($sessionId, array_keys($categoryMap));
        return $this->buildFullToolset($categoryMap, $allAbilities);
    }

    /**
     * Returns the configured provider slug, if available.
     */
    private function getActiveProviderId(): ?string
    {
        try {
            return $this->getSelectedModel()['provider'];
        } catch (\Throwable $e) {
            return null;
        }
    }

    /**
     * Minimal routed toolset for Gemini when keywords do not match.
     *
     * @param array<string, array<string, mixed>> $categoryMap
     * @param array<string, array<string, mixed>> $allAbilities
     * @return array{abilities: array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration>, sops: string}
     */
    private function buildGoogleDiscoverFirstIntent(array $categoryMap, array $allAbilities): array
    {
        $intent = $this->buildToolset(['global', 'meta', 'memory'], $categoryMap, $allAbilities);
        $discoverSop = 'SOP (Gemini): Start with getDiscoveredAbilities(query=...) when you need a specific tool. '
            . 'Call requestAdditionalCategories with the category slug before requestFullToolset.';

        return [
            'abilities' => $intent['abilities'],
            'sops' => trim(($intent['sops'] ?? '') . "\n\n" . $discoverSop),
        ];
    }

    /**
     * Builds the classifier prompt dynamically from the available category map.
     *
     * The prompt instructs the LLM to return a JSON array of matching category
     * slugs based on the user message. Only actionable categories (excluding
     * 'global' and 'uncategorized') are listed.
     *
     * @param string $userMessage  The user's message to classify.
     * @param array  $categoryMap  Map of category => ['tools' => [...], 'keywords' => [...]].
     * @return string The classifier system prompt.
     */
    private function buildClassifierPrompt(string $userMessage, array $categoryMap, int $maxCategories = 3): string
    {
        $categoryList = [];
        foreach ($categoryMap as $category => $data) {
            if ($category === 'global' || $category === 'uncategorized') {
                continue;
            }
            $toolNames = implode(', ', $data['tools'] ?? []);
            $categoryList[] = "- {$category}: [{$toolNames}]";
        }

        $categoriesBlock = implode("\n", $categoryList);

        return "You are a silent intent classifier for a WordPress AI assistant.\n"
            . "Given a user message, return a JSON array of category slugs that are relevant.\n"
            . "Return ONLY the JSON array, nothing else. No markdown, no explanation.\n\n"
            . "Available categories:\n"
            . $categoriesBlock . "\n\n"
            . "Rules:\n"
            . "- Return 1-{$maxCategories} categories maximum.\n"
            . "- If the message is purely conversational (chitchat, thanks, goodbye), return [].\n"
            . "- If uncertain, prefer broader categories over narrow ones.\n\n"
            . "User message: \"" . $userMessage . "\"";
    }

    /**
     * Calls the LLM as a micro-agent to classify intent into categories.
     *
     * Uses the same model configured for the main chat but with minimal
     * token budget (max_tokens=50) to keep latency and cost low.
     *
     * @param string $userMessage  The user's message to classify.
     * @param array  $categoryMap  Map of category => data.
     * @return array Array of classified category slugs (may be empty).
     * @throws \RuntimeException If the model cannot be loaded.
     */
    private function classifyIntent(string $userMessage, array $categoryMap): array
    {
        $maxCategories = $this->getAdaptiveClassifierCap();
        $classifierPrompt = $this->buildClassifierPrompt($userMessage, $categoryMap, $maxCategories);

        $modelConfig = $this->getSelectedModel();
        try {
            $model = AiClient::defaultRegistry()->getProviderModel($modelConfig['provider'], $modelConfig['model']);
        } catch (\Exception $e) {
            throw new \RuntimeException(esc_html(ErrorMessages::classifierFailed($e->getMessage())));
        }

        $model->getConfig()->setSystemInstruction(
            'You are a JSON classifier. Respond with a valid JSON array of strings only.'
        );

        // Limit output tokens if the SDK supports it (not available in all WP 7.0 builds).
        $config = $model->getConfig();
        if (method_exists($config, 'setMaxOutputTokens')) {
            $config->setMaxOutputTokens(50);
        }

        $classifierOptions = new RequestOptions();
        $classifierOptions->setTimeout(15.0);
        $classifierOptions->setConnectTimeout(5.0);
        $model->setRequestOptions($classifierOptions);

        $messages = [
            new \WordPress\AiClient\Messages\DTO\Message(
                \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(),
                [new \WordPress\AiClient\Messages\DTO\MessagePart($classifierPrompt)]
            )
        ];

        // Sprint 1 P0-D: force non-think mode for DeepSeek V4 classifier.
        // The task is a trivial JSON categorization — thinking adds 2-5s of
        // unnecessary latency. Parameter verified against DeepSeek docs:
        // https://api-docs.deepseek.com/guides/thinking_mode
        // The classifier sends no tools, but we route through the same single-pass
        // filter so the body decode/encode stays consistent across all call sites.
        $disableThinking = $this->shouldDisableThinking($modelConfig);
        $filterCallback = null;
        if ($disableThinking) {
            $filterCallback = function ($args) {
                return $this->fixOutgoingRequestBody($args, [], true);
            };
            \add_filter('http_request_args', $filterCallback, 10, 1);
        }

        try {
            $result = $model->generateTextResult($messages);
        } finally {
            if ($filterCallback !== null) {
                \remove_filter('http_request_args', $filterCallback, 10);
            }
        }

        $candidate = $result->getCandidates()[0];
        $responseText = '';
        foreach ($candidate->getMessage()->getParts() as $part) {
            if ($part->getType()->isText() && !$part->getChannel()->isThought()) {
                $responseText .= $part->getText();
            }
        }

        return $this->parseClassifierResponse($responseText, $categoryMap, $maxCategories);
    }

    /**
     * Parses the classifier LLM response into an array of valid category slugs.
     *
     * Handles common LLM output quirks:
     * - Markdown code fences (```json ... ```)
     * - Trailing text after the JSON array
     * - Invalid or malformed JSON
     *
     * @param string $response    Raw text from the classifier LLM.
     * @param array  $categoryMap Valid categories to validate against.
     * @return array Validated category slugs.
     */
    private function parseClassifierResponse(string $response, array $categoryMap, int $maxCategories = 3): array
    {
        $cleaned = trim($response);

        // Strip markdown code fences if present
        $cleaned = preg_replace('/^```(?:json)?\s*/i', '', $cleaned);
        $cleaned = preg_replace('/\s*```\s*$/', '', $cleaned);
        $cleaned = trim($cleaned);

        // Extract the first JSON array from the response
        if (preg_match('/\[.*?\]/s', $cleaned, $matches)) {
            $cleaned = $matches[0];
        }

        $decoded = json_decode($cleaned, true);
        if (!is_array($decoded)) {
            self::debugLog('[LolaCore Router] Classifier decode failed: ' . substr($response, 0, 120));
            return [];
        }

        // Validate each category against the known map
        $validCategories = array_keys($categoryMap);
        $result = [];
        foreach ($decoded as $cat) {
            if (is_string($cat) && in_array($cat, $validCategories, true)) {
                $result[] = $cat;
            }
        }

        return array_slice($result, 0, $maxCategories);
    }

    private function isContextualConfirmation(string $message): bool
    {
        $pattern = '/^(sÃ­|si|ok|okay|vale|hazlo|procede|adelante|dale|yes|do it|proceed|go ahead)\.?$/iu';
        return (bool) preg_match($pattern, trim($message));
    }

    /**
     * Detects meta-queries about Lola's own capabilities.
     *
     * Returns true when the user is asking what Lola can do, listing tools,
     * or exploring capabilities — questions that should trigger the full
     * toolset injection with a self-introspection SOP.
     *
     * Patterns use regex character classes (`qu[eé]`, `cu[aá]ntas`) so a single
     * pattern matches both the accented and unaccented form. This fixes a bug
     * present in earlier versions where mojibake-encoded literals (`quÃ©`)
     * never matched real UTF-8 input from the chat.
     *
     * @param string $text Combined context window (lowercase).
     * @return bool True if meta-intent is detected.
     */
    private function isMetaToolQuery(string $text): bool
    {
        $patterns = [
            // Localized — char classes cover accented/unaccented variants
            'herramienta',
            'qu[eé]\s+puedes\s+hacer',
            'qu[eé]\s+sabes\s+hacer',
            'qu[eé]\s+funciones',
            'lista.*capacidad',
            'lista.*herramienta',
            'cu[aá]ntas\s+herramienta',
            'tus\s+capacidad',
            'funcionalidad.*dispon',
            // pt-BR
            'ferramenta',
            'habilidade',
            'o\s+que\s+voc[eê]\s+(pode|sabe)\s+fazer',
            'quais\s+func[oõ]es',
            'lista.*ferramenta',
            'lista.*habilidade',
            'quantas\s+ferramenta',
            'suas\s+capacidade',
            'funcionalidade.*dispon',
            // English
            'what\s+can\s+you\s+do',
            'what\s+tools',
            'list.*tools',
            'list.*capabilit',
            'your\s+capabilit',
            'your\s+abilities',
            'how\s+many\s+tools',
            'what\s+are\s+you\s+able',
            'show.*tools',
        ];

        foreach ($patterns as $p) {
            if (preg_match('/' . $p . '/iu', $text)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Detects holistic monitoring queries that require all domains.
     *
     * Returns true when the user is asking for a broad business overview,
     * daily summary, or general status check — queries that should trigger
     * the full toolset with a holistic SOP rather than routing to a single domain.
     *
     * Patterns for the most common locales ship in core. Patterns for
     * additional locales can be added by addons or sites via the
     * `lolacore_holistic_monitoring_patterns` filter.
     *
     * @param string $text Combined context window (lowercase).
     * @return bool True if holistic monitoring intent is detected.
     */
    private function isHolisticMonitoringQuery(string $text): bool
    {
        $patterns = [
            // English
            '/\bwhat\s+needs\s+my\s+attention\b/i',
            '/\bwhat\s+should\s+i\s+look\s+at\b/i',
            '/\bwhat\s+requires\s+(my\s+)?action\b/i',
            '/\bdaily\s+summary\b/i',
            '/\bmorning\s+briefing\b/i',
            '/\bdaily\s+status\b/i',
            '/\bhow\s+is\s+the\s+business\s+doing\b/i',
            '/\bhow\s+are\s+things\b/i',
            '/\bhow\W*s\s+everything\b/i',
            '/\bwhat\W*s\s+going\s+on\b/i',
            '/\bwhat\W*s\s+happening\b/i',
            '/\banything\s+important\b/i',
            '/\boverall\s+status\b/i',
            '/\bbusiness\s+overview\b/i',
            '/\bgive\s+me\s+an?\s+overview\b/i',
            '/\bquick\s+status\b/i',
            '/\bstatus\s+report\b/i',
            // Localized — char classes cover accented/unaccented variants
            '/\bqu[eé]\s+necesita\s+(mi\s+)?atenci[oó]n\b/iu',
            '/\bqu[eé]\s+(debo|tengo\s+que)\s+(mirar|revisar|atender)\b/iu',
            '/\bqu[eé]\s+hay\s+de\s+nuevo\b/iu',
            '/\bresumen\s+(diario|del\s+d[ií]a|general)\b/iu',
            '/\bestado\s+general\b/iu',
            '/\bc[oó]mo\s+(va|est[aá])\s+(todo|el\s+negocio|el\s+sitio)\b/iu',
            '/\binforme\s+(diario|general)\b/iu',
            '/\bvisi[oó]n\s+general\b/iu',
            '/\bdame\s+un\s+resumen\b/iu',
            // pt-BR
            '/\bo\s+que\s+precisa\s+da\s+minha\s+aten[cç][aã]o\b/iu',
            '/\bo\s+que\s+(devo|tenho\s+que)\s+(ver|revisar|atender)\b/iu',
            '/\bo\s+que\s+h[aá]\s+de\s+novo\b/iu',
            '/\bresumo\s+(di[aá]rio|geral|do\s+dia)\b/iu',
            '/\bestado\s+geral\b/iu',
            '/\bcomo\s+(vai|est[aá])\s+(tudo|o\s+neg[oó]cio|o\s+site)\b/iu',
            '/\brelat[oó]rio\s+(di[aá]rio|geral)\b/iu',
            '/\bvis[aã]o\s+geral\b/iu',
            '/\bme\s+d[eê]\s+um\s+resumo\b/iu',
        ];

        /**
         * Filters holistic monitoring query patterns.
         *
         * Allows addons or site-level integrations to add localized patterns
         * for holistic monitoring detection. Each pattern should be a valid
         * regex string with delimiters (e.g., '/\bpattern\b/iu').
         *
         * @since 1.1.5
         *
         * @param string[] $patterns Array of regex patterns.
         */
        $patterns = \apply_filters('lolacore_holistic_monitoring_patterns', $patterns);

        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $text)) {
                self::debugLog('[LolaCore Router] Holistic pattern matched.');
                return true;
            }
        }

        return false;
    }

    /**
     * Determines the adaptive classifier category cap based on active premium addons.
     *
     * With more addons active, the classifier needs to return more categories
     * to properly cover multi-domain queries. The cap scales:
     * - 0-1 premium addons → 3 categories (default behavior)
     * - 2 premium addons   → 4 categories
     * - 3+ premium addons  → 5 categories
     *
     * @return int Maximum categories the classifier may return.
     */
    private function getAdaptiveClassifierCap(): int
    {
        /** @var array $addonDomains Domains registered by premium addons via filter. */
        $addonDomains = \apply_filters('lolacore_addon_domains', []);
        $premiumCount = count($addonDomains);

        if ($premiumCount >= 3) {
            return 5;
        }

        if ($premiumCount >= 2) {
            return 4;
        }

        return 3;
    }

    private function cacheMatchedCategories(string $sessionId, array $categories): void
    {
        $hash = md5($sessionId);
        \set_transient('lolacore_cat_cache_' . $hash, $categories, 12 * HOUR_IN_SECONDS);
    }

    private function getLastMatchedCategories(string $sessionId): array
    {
        $hash = md5($sessionId);
        $cached = \get_transient('lolacore_cat_cache_' . $hash);
        return is_array($cached) ? $cached : [];
    }

    /**
     * @return list<string>
     */
    private function getExpandedCategories(string $sessionId): array
    {
        $hash = md5($sessionId);
        $cached = \get_transient('lolacore_expanded_cats_' . $hash);
        return is_array($cached) ? $cached : [];
    }

    /**
     * Union of last routed categories and session-expanded categories.
     *
     * @return list<string>
     */
    private function getSessionToolsetCategories(string $sessionId): array
    {
        $routed = $this->getLastMatchedCategories($sessionId);
        $expanded = $this->getExpandedCategories($sessionId);

        return array_values(array_unique(array_merge($routed, $expanded)));
    }

    private function buildContextWindow(array $history, string $currentMessage): string
    {
        $userMessages = [];
        foreach ($history as $msg) {
            if (($msg['role'] ?? '') === 'user' && !empty($msg['content'])) {
                $userMessages[] = $msg['content'];
            }
        }
        
        $recentUser = array_slice($userMessages, -2);
        $recentUser[] = $currentMessage;
        
        $combined = implode(' ', $recentUser);
        return strtolower($combined);
    }

    private function matchByKeywords(string $contextText, array $categoryMap): array
    {
        $matched = [];
        $text = ' ' . mb_strtolower(preg_replace('/[^\p{L}\s]/u', ' ', $contextText)) . ' ';
        $tokens = array_filter(explode(' ', $text));

        foreach ($categoryMap as $category => $data) {
            if ($category === 'global' || $category === 'uncategorized' || $category === 'meta') {
                continue;
            }
            
            $keywords = $data['keywords'] ?? ['exact' => [], 'stem' => []];
            $exactMatches = $keywords['exact'] ?? [];
            $stemMatches = $keywords['stem'] ?? [];
            
            $categoryMatched = false;
            
            foreach ($exactMatches as $kw) {
                if (preg_match('/\b' . preg_quote($kw, '/') . '\b/iu', $text)) {
                    $categoryMatched = true;
                    break;
                }
            }
            
            if (!$categoryMatched) {
                foreach ($stemMatches as $stem) {
                    if (mb_strlen($stem) < 3) continue;
                    foreach ($tokens as $token) {
                        if (strpos($token, $stem) !== false) {
                            $categoryMatched = true;
                            break 2;
                        }
                    }
                }
            }
            
            if ($categoryMatched) {
                $matched[] = $category;
            }
        }
        
        return $matched;
    }

    /**
     * Resolves matched categories into intent data with FunctionDeclarations and SOPs.
     *
     * @param array $matchedCategories Categories to resolve.
     * @param array $categoryMap       Full category map.
     * @param array $allAbilities      All discovered abilities.
     * @param bool  $isFallback        Whether this is a fallback resolution.
     * @return array Intent data with 'abilities' and 'sops'.
     */
    private function resolveCategoriesToIntentData(array $matchedCategories, array $categoryMap, array $allAbilities, bool $isFallback = false): array
    {
        if ($isFallback) {
            return $this->buildFullToolset($categoryMap, $allAbilities);
        }

        // If the classifier returned 'meta', treat as a full-toolset meta-query.
        // This is the Layer 2 safety net for meta-intent detection.
        if (in_array('meta', $matchedCategories, true)) {
            self::debugLog('[LolaCore Router] Classifier returned meta -> full toolset.');
            return $this->buildFullToolset($categoryMap, $allAbilities, true);
        }

        // Expand matched categories with their affinity siblings
        // e.g. woocommerce â†’ also include woocommerce_pro
        $expandedCategories = $this->expandCategoryAffinity($matchedCategories, $categoryMap);

        return $this->buildToolset($expandedCategories, $categoryMap, $allAbilities);
    }

    /**
     * Expands matched categories by including affinity siblings.
     *
     * When the intent router matches a category (e.g. "woocommerce"),
     * this method also includes related categories (e.g. "woocommerce_pro")
     * if they exist in the current category map. This ensures addon tools
     * are always available alongside their base domain tools.
     *
     * @param array $matchedCategories Categories matched by the router.
     * @param array $categoryMap       Full category map (used to validate siblings exist).
     * @return array Expanded list of categories.
     */
    private function expandCategoryAffinity(array $matchedCategories, array $categoryMap): array
    {
        $affinityMap = $this->getCategoryAffinityMap();
        $expanded = $matchedCategories;

        foreach ($matchedCategories as $cat) {
            if (isset($affinityMap[$cat])) {
                foreach ($affinityMap[$cat] as $sibling) {
                    // Only add if the sibling actually exists in the current map
                    if (isset($categoryMap[$sibling]) && !in_array($sibling, $expanded, true)) {
                        $expanded[] = $sibling;
                    }
                }
            }
        }

        if (count($expanded) > count($matchedCategories)) {
            self::debugLog(
                '[LolaCore Router] Affinity expansion: '
                . self::summarizeListForLog($matchedCategories)
                . ' -> '
                . self::summarizeListForLog($expanded)
            );
        }

        return $expanded;
    }

    /**
     * Returns the category affinity map.
     *
     * Each entry maps a category slug to an array of sibling categories
     * that should always be co-included when the key category is matched.
     * Relationships are bidirectional: if A lists B, B should list A.
     *
     * Filterable via 'lolacore_category_affinity' so addons can register
     * their own pairings at runtime.
     *
     * @return array<string, string[]> Map of category => sibling categories.
     */
    private function getCategoryAffinityMap(): array
    {
        $map = [
            'woocommerce'     => ['woocommerce_pro'],
            'woocommerce_pro' => ['woocommerce'],
        ];

        /**
         * Filters the category affinity map.
         *
         * Allows addon plugins to register bidirectional category pairings
         * so their tools are always co-included with related base tools.
         *
         * @since 1.1.0
         *
         * @param array $map Map of category slug => array of sibling slugs.
         */
        return \apply_filters('lolacore_category_affinity', $map);
    }

    /**
     * Builds a toolset from specific matched categories.
     *
     * Converts category + tool names into FunctionDeclaration objects
     * ready for the AI model, and generates a contextual SOP.
     *
     * @param array $matchedCategories Categories to include.
     * @param array $categoryMap       Full category map.
     * @param array $allAbilities      All discovered abilities.
     * @return array Intent data with 'abilities' and 'sops'.
     */
    private function buildToolset(array $matchedCategories, array $categoryMap, array $allAbilities): array
    {
        $selectedToolNames = [];

        // Always include global tools
        if (isset($categoryMap['global'])) {
            $selectedToolNames = array_merge($selectedToolNames, $categoryMap['global']['tools']);
        }

        // Always include meta tools (escalation)
        if (isset($categoryMap['meta'])) {
            $selectedToolNames = array_merge($selectedToolNames, $categoryMap['meta']['tools']);
        }

        // Always include memory tools (save/recall facts)
        if (isset($categoryMap['memory'])) {
            $selectedToolNames = array_merge($selectedToolNames, $categoryMap['memory']['tools']);
        }

        foreach ($matchedCategories as $cat) {
            if (isset($categoryMap[$cat])) {
                $selectedToolNames = array_merge($selectedToolNames, $categoryMap[$cat]['tools']);
            }
        }

        $selectedToolNames = array_unique($selectedToolNames);

        $functionDeclarations = [];
        foreach ($selectedToolNames as $name) {
            if (!isset($allAbilities[$name])) {
                continue;
            }

            $abilitySchema = $allAbilities[$name];
            $params          = ToolParameterSchema::normalize((array) ( $abilitySchema['parameters'] ?? array() ));
            $functionDeclarations[] = new \WordPress\AiClient\Tools\DTO\FunctionDeclaration(
                $name,
                $abilitySchema['description'] ?? '',
                $params
            );
        }

        $sop = $this->getSopForCategory($matchedCategories);

        return [
            'abilities' => $functionDeclarations,
            'sops' => $sop
        ];
    }

    /**
     * Builds a full toolset with all categories for the fallback path.
     *
     * @param array $categoryMap      Full category map.
     * @param array $allAbilities     All discovered abilities.
     * @param bool  $isMetaQuery      Whether this was triggered by a meta-intent.
     * @param bool  $isHolisticQuery  Whether this was triggered by a holistic monitoring query.
     * @return array Intent data with 'abilities' and 'sops'.
     */
    private function buildFullToolset(array $categoryMap, array $allAbilities, bool $isMetaQuery = false, bool $isHolisticQuery = false): array
    {
        $allToolNames = [];
        foreach ($categoryMap as $data) {
            $allToolNames = array_merge($allToolNames, $data['tools'] ?? []);
        }
        $allToolNames = array_unique($allToolNames);

        $functionDeclarations = [];
        foreach ($allToolNames as $name) {
            if (!isset($allAbilities[$name])) {
                continue;
            }

            $abilitySchema = $allAbilities[$name];
            $params          = ToolParameterSchema::normalize((array) ( $abilitySchema['parameters'] ?? array() ));
            $functionDeclarations[] = new \WordPress\AiClient\Tools\DTO\FunctionDeclaration(
                $name,
                $abilitySchema['description'] ?? '',
                $params
            );
        }

        if ($isMetaQuery) {
            $sop = 'SOP: The user is asking about your capabilities or available tools. '
                . 'Call the getDiscoveredAbilities tool to get the full, real-time list of all ' . count($functionDeclarations) . ' tools registered on this site. '
                . 'Present the results clearly, grouped by category. Do NOT guess or list tools from memory — always use the tool output.';
        } elseif ($isHolisticQuery) {
            $sop = 'SOP: The user wants a holistic business status overview. '
                . 'You have access to ' . count($functionDeclarations) . ' tools across all domains. '
                . 'Use READ-ONLY tools from each active domain to gather current state: '
                . 'support tickets (open/pending), WooCommerce orders/revenue, plugin health, security status, pending updates. '
                . 'Compile the results into a concise, prioritized dashboard summary. '
                . 'Flag anything that requires immediate action. '
                . 'Do NOT modify anything — this is a read-only monitoring pass.';
        } else {
            $sop = 'SOP: You have access to a broad set of ' . count($functionDeclarations) . ' tools across multiple domains. '
                . 'Use read tools first to gather context before suggesting write actions. '
                . 'If the user\'s request is ambiguous, ask a clarifying question before acting.';
        }

        return [
            'abilities' => $functionDeclarations,
            'sops' => $sop
        ];
    }

    private function getSopForCategory(array $categories): string
    {
        $safety = \LolaCore\Chat\SafetyConfig::fromDatabase();

        $sopMap = [
            'content'      => 'SOP: For content operations, always read the existing post/page first (getContent/getPostMeta) before suggesting edits. Show the user what will change.',
            'plugins'      => 'SOP: For plugin operations, check current status with auditPlugins before modifying.',
            'woocommerce'      => 'SOP: For WooCommerce operations, verify the store is active. Read current product/order data before modifying. Prices are strings.',
            'woocommerce_pro'  => 'SOP: For WooCommerce Pro operations (analytics, bulk actions, refunds), read current state first. Bulk operations always preview before executing. Revenue/order stats support period comparison.',
            'seo'          => 'SOP: For SEO operations, run diagnoseSeo first. Then use getPostMeta to read current meta values before updating with updatePostMeta.',
            'security'     => 'SOP: For security operations, run auditSecurity first. Explain findings clearly. Never downplay critical issues.',
            'database'     => 'SOP: For database operations, always run getDatabaseStatus first.',
            'users'        => 'SOP: For user operations, verify the current user list first. Never delete without reassign_to. Warn about admin role changes.',
            'themes'       => 'SOP: For theme operations, warn that switching themes may break menus, widgets, and customizer settings. Check current theme first.',
            'cache'        => 'SOP: For cache operations, explain which caching plugin was detected and what will be purged.',
            'taxonomies'   => 'SOP: For taxonomy operations, list existing terms before creating duplicates. Respect hierarchy.',
            'comments'     => 'SOP: For comment operations, show the comment content before taking destructive action. Batch carefully.',
            'media'        => 'SOP: For media operations, warn that deletion is permanent and removes the physical file. Check if media is in use.',
            'menus'        => 'SOP: For menu operations, list current structure first. On block themes, wp_navigation menus use getNavigationMenus and editNavigationMenu (FSE) — NOT getMenus/manageMenus (classic nav_menu). On classic themes, use getMenus and manageMenus. Never edit template parts when the user asked to change menu links.',
            'system'       => 'SOP: For system operations, be careful with wp_options changes. Some options break the site if misconfigured.',
            'plugin_builder' => <<<'PB_SOP'
SOP — Plugin Builder. One lifecycle, two phases; pick the phase from the conversation. Speak to the user in your own plain, first-person voice — no jargon, no flattery, one clear next step at a time.

DESIGN (creating a plugin):
1. Call getSiteRenderContext FIRST. Let the host environment decide the build: classic hooks may not fire under a block theme — when is_block_theme is true or a WooCommerce page uses block integration, choose a block-compatible approach, not classic content hooks.
2. Co-design briefly. Ask only what you cannot introspect: the user's real goal, thresholds, copy, and where it should appear. Adaptive depth — a clear request earns a compact PRD; a vague one earns a couple of sharp questions first. One question at a time.
3. Present a short, scannable mini-PRD in your voice — what it does, where it hooks, and the environment facts that shape it (block vs classic rendering) — and get explicit approval before building.
4. Call createPlugin(description, prd) with the approved PRD verbatim, so the generator honors the agreed scope and environment.
5. After the plugin is saved, the user must test it in Playground and confirm it works before you may install it on their live site. Never call installPluginLocal until that Playground gate is attested for the current version. Never call testPluginPlayground after saving — the saved plugin card opens Playground automatically; point the user to Open in Playground on that card.

ITERATE (debugging it live on the site):
1. Verify reality, never infer it. Call getInstalledPluginState(post_id) to read the on-disk code, the live version, and any drift from the library before you diagnose. Recheck getSiteRenderContext if the environment is in question.
2. You are blind to rendering. For any visual symptom (nothing shows, wrong color, wrong place), ASK the user to describe or screenshot what they see — never reason about CSS or visual output from code alone. The user is your eyes.
3. Diagnose against the PRD. Load getBuiltPluginDetails(post_id), trace each feature to its integration point, and list concrete findings, each with its fix.
4. Gate the install on a backup. Run installPluginLocal as a dry run, read its backup_advisory, and ask the user plainly to confirm they have a recent backup before you proceed — a strong recommendation, not a hard block.
5. Never call installPluginLocal until the user has attested Playground testing for the current library version (gate_status attested, matching version). If they ask to install before that, tell them to open Playground from the saved plugin card, check the settings page and front end, and tap Install on my site when it matches what they asked for.
6. Apply every finding in ONE rich updatePluginCode(post_id, instruction). It regenerates the whole plugin — never ask the user for code diffs.
7. Close the loop: after installing, confirm with getInstalledPluginState that the new version is live and in sync, then ask the user to re-verify the visual result.
PB_SOP,
        ];

        if ($safety->requireBackup) {
            $sopMap['plugins'] .= ' Warn about backups before updates.';
            $sopMap['database'] .= ' Warn that optimization is irreversible. Recommend backup.';
        }

        /**
         * Filters the SOP map to allow addon plugins to inject SOPs for custom categories.
         *
         * @since 1.0.1
         *
         * @param array $sopMap Map of category slug => SOP instruction string.
         */
        $sopMap = apply_filters( 'lolacore_sop_map', $sopMap );

        $parts = [];
        foreach ($categories as $cat) {
            if (isset($sopMap[$cat])) {
                $parts[] = $sopMap[$cat];
            }
        }

        if (empty($parts)) {
            return 'SOP: Respond and execute actions within the context of the user request. Explain what you will do before invoking tools.';
        }

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

    private function discoverAvailableAbilities(): array
    {
        try {
            $abilities = $this->abilitiesRegistry->getRegisteredAbilities();
            if (!empty($abilities)) {
                $sample = reset($abilities);
                assert(isset($sample['category']), 'Abilities must have a category metadata field.');
            }
        } catch (\Throwable $e) {
            self::debugLog('[LolaCore Router] CRITICAL: failed to load abilities: ' . $e->getMessage());
            return [];
        }
        self::debugLog('[LolaCore Router] Abilities discovered: ' . self::summarizeAbilityRegistry($abilities));
        return $abilities;
    }

    private function isGreeting(string $messageLower): bool
    {
        if ($messageLower === '' || mb_strlen($messageLower) > 40) {
            return false;
        }
        $pattern = '/^(hola|hello|hi|hey|buenas|buenos\s+d[Ã­i]as|buenas\s+tardes|buenas\s+noches|quÃ©\s+tal|que\s+tal|saludos)\b[\s\.,!Â¡Â¿\?]*(lola)?[\s\.,!Â¡Â¿\?]*$/u';
        return (bool) preg_match($pattern, $messageLower);
    }

    private function buildSystemPrompt(string $sops, string $userMessage = '', bool $hasTools = false, string $sessionId = ''): string
    {
        $techLevel = $this->getConfigValue('technical_level', 'Professional');
        $memoryContext = $this->contextBuilder->buildContext($userMessage);
        $lastSessionSummary = $this->contextBuilder->getLastSessionSummary();
        $scanSummary = $this->scanner->getScanSummary();

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

        // Publish the current message + intent to the Skills.MD engine
        // BEFORE the prompt is built — the `lolacore_system_prompt` filter
        // (priority 20) consumes this slot to match active skills.
        if (class_exists(\LolaCore\Skills\SkillInjector::class)) {
            \LolaCore\Skills\SkillInjector::setCurrentMessage($userMessage, $sops !== '' ? 'with_sop' : '');
        }

        if (class_exists(\LolaCore\Playbooks\PlaybookInjector::class)) {
            \LolaCore\Playbooks\PlaybookInjector::setCurrentMessage($userMessage, $sops !== '' ? 'with_sop' : '');
            try {
                $selected = $this->getSelectedModel();
                \LolaCore\Playbooks\PlaybookInjector::setCurrentModel(
                    (string) ($selected['provider'] ?? ''),
                    (string) ($selected['model'] ?? '')
                );
            } catch (\Throwable $e) {
                \LolaCore\Playbooks\PlaybookInjector::setCurrentModel('', '');
            }
        }

        $prompt = $this->systemPromptBuilder->build($memoryContext, $lastSessionSummary, $scanSummary, $techLevel, $safety, $hasTools);

        if (!empty($sops)) {
            $prompt .= "\n\nACTIVE SOP:\n" . $sops;
        }

        return $prompt;
    }

    private function getConfigValue(string $key, $default = null)
    {
        global $wpdb;
        $table = $wpdb->prefix . 'lolacore_config';
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
        $value = $wpdb->get_var($wpdb->prepare("SELECT config_value FROM {$table} WHERE config_key = %s", $key));
        return $value ?? $default;
    }

    private function getSelectedModel(): array
    {
        return SelectedModelResolver::fromConfig(
            $this->getConfigValue('selected_model', 'auto'),
            ErrorMessages::noProviderConfigured()
        );
    }

    /**
     * Converts raw array history to WP AI Client Message objects.
     *
     * History is sanitized first so that any assistant tool_call_id without a
     * subsequent tool message gets a synthetic placeholder. OpenAI-compatible
     * APIs (DeepSeek, OpenAI) reject conversations with unmatched tool_call_ids
     * with HTTP 400 ("insufficient tool messages"), and an orphan from one
     * abandoned confirmation poisons every later turn in the session.
     */
    private function buildRawMessages(array $history, string $newMessage): array
    {
        $history = $this->sanitizeHistoryForApi($history);

        $messages = [];

        foreach ($history as $msg) {
            // Tool results require their own dedicated Message with a single
            // FunctionResponse part. The WP AI Client SDK enforces this
            // constraint: a FunctionResponse MUST be the only part in its
            // Message, otherwise prepareMessagesParam() throws.
            if ($msg['role'] === 'tool') {
                $responseData = json_decode($msg['content'], true) ?? ['result' => $msg['content']];
                $messages[] = new \WordPress\AiClient\Messages\DTO\Message(
                    \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(),
                    [new \WordPress\AiClient\Messages\DTO\MessagePart(
                        new \WordPress\AiClient\Tools\DTO\FunctionResponse(
                            $msg['tool_call_id'],
                            $msg['name'] ?? '',
                            $responseData
                        )
                    )]
                );
                continue;
            }

            $role = $msg['role'] === 'user'
                ? \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user()
                : \WordPress\AiClient\Messages\Enums\MessageRoleEnum::model();
            $parts = [];

            if (!empty($msg['content'])) {
                $parts[] = new \WordPress\AiClient\Messages\DTO\MessagePart($msg['content']);
            }

            if (!empty($msg['tool_calls'])) {
                foreach ($msg['tool_calls'] as $tc) {
                    $args = $this->decodeFunctionCallArguments($tc['function']['arguments'] ?? '{}');
                    $parts[] = new \WordPress\AiClient\Messages\DTO\MessagePart(
                        new \WordPress\AiClient\Tools\DTO\FunctionCall($tc['id'], $tc['function']['name'], $args)
                    );
                }
            }

            if (!empty($parts)) {
                $messages[] = new \WordPress\AiClient\Messages\DTO\Message($role, $parts);
            }
        }

        if (!empty($newMessage)) {
            $finalMessage = $newMessage;
            if ($newMessage === self::TOOL_RESULTS_CONTINUE) {
                $finalMessage = "System: The requested actions have been executed. The results are provided above. Please review the results, summarize what was done, and ask if the user needs anything else.";
            } elseif ($newMessage === self::DEFERRED_INTENT_CONTINUE) {
                $finalMessage = 'System: You told the user you would look something up or proceed, but you ended the turn without calling any tools. Call the required read tools NOW in this turn. Do not restate your intent or ask the user to confirm — execute the tool calls immediately.';
            }

            $messages[] = new \WordPress\AiClient\Messages\DTO\Message(
                \WordPress\AiClient\Messages\Enums\MessageRoleEnum::user(),
                [new \WordPress\AiClient\Messages\DTO\MessagePart($finalMessage)]
            );
        }

        return $messages;
    }

    /**
     * Guarantees a non-empty tool_call_id for downstream pairing.
     *
     * The OpenAI-compatible response spec requires every tool_call to carry an
     * id, but in rare cases providers (or SDK edge paths) yield null/empty.
     * An empty id silently breaks the assistant→tool round-trip pairing and
     * produces an "insufficient tool messages" 400 on the next continuation.
     */
    private function ensureToolCallId(?string $id): string
    {
        if ($id !== null && $id !== '') {
            return $id;
        }
        return 'call_' . \wp_generate_uuid4();
    }

    /**
     * Validates pairing of assistant tool_calls and following tool messages,
     * injecting synthetic tool responses for any orphan tool_call_ids.
     *
     * The OpenAI Chat Completions contract (followed by DeepSeek) requires
     * every tool_call_id emitted in an assistant message to be answered by a
     * tool message before the next assistant/user turn. Orphans can arise from:
     *   - Abandoned confirmation UIs (user closes tab without approve/cancel).
     *   - Uncaught \Throwable in the executor path that skips saveMessage.
     *   - tool_call.id missing or null in the provider response.
     *   - Past sessions written before defensive captures were in place.
     *
     * Without this sanitization, a single historical orphan poisons every
     * subsequent continueAfterToolExecution call in the session with a 400.
     *
     * Walk semantics:
     *   - Assistant tool_calls open pending IDs in arrival order.
     *   - Tool messages close their matching ID (if any).
     *   - Any role other than 'tool' that arrives with pending IDs flushes
     *     them as synthetic tool placeholders BEFORE the new turn.
     *   - End of history also flushes (so the upcoming user/system message
     *     does not arrive on top of an open tool_call set).
     *
     * @param array $history Raw history rows as produced by getSessionHistory().
     * @return array Sanitized history with synthetic tool messages inserted.
     */
    private function sanitizeHistoryForApi(array $history): array
    {
        $sanitized = [];
        $pending = [];  // tool_call_id => ['name' => ...]
        $flushedIds = [];  // collect all orphan IDs for logging
        $allDeclaredIds = [];  // track every tool_call_id declared by any assistant

        $flush = function () use (&$pending, &$sanitized, &$flushedIds) {
            if (empty($pending)) return;
            foreach ($pending as $tcId => $info) {
                $sanitized[] = [
                    'role' => 'tool',
                    'content' => wp_json_encode([
                        'result' => 'No result was recorded for this action. The execution may have been interrupted or the confirmation abandoned.',
                    ]),
                    'tool_call_id' => $tcId,
                    'name' => $info['name'] ?? '',
                ];
                $flushedIds[] = $tcId . ' (' . ($info['name'] ?? '?') . ')';
            }
            $pending = [];
        };

        foreach ($history as $msg) {
            $role = $msg['role'] ?? '';

            if ($role === 'assistant') {
                // Flush before queueing new tool_calls so two consecutive
                // orphan-bearing assistant turns each get their own placeholders.
                $flush();
                $sanitized[] = $msg;
                if (!empty($msg['tool_calls'])) {
                    foreach ($msg['tool_calls'] as $tc) {
                        $tcId = $tc['id'] ?? '';
                        if ($tcId === '' || $tcId === null) {
                            continue; // tracked separately by ensureToolCallId upstream
                        }
                        $pending[$tcId] = [
                            'name' => $tc['function']['name'] ?? '',
                        ];
                        $allDeclaredIds[$tcId] = true;
                    }
                }
                continue;
            }

            if ($role === 'tool') {
                $tcId = $msg['tool_call_id'] ?? '';
                if ($tcId !== '' && isset($pending[$tcId])) {
                    unset($pending[$tcId]);
                }
                // Drop stale tool messages that reference a tool_call_id never
                // declared by any assistant in this history. These confuse models
                // because they appear as tool responses without a preceding call.
                if ($tcId !== '' && !isset($allDeclaredIds[$tcId])) {
                    if (defined('WP_DEBUG') && WP_DEBUG) {
                        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Sanitizer telemetry.
                        error_log('[LolaCore] sanitizeHistoryForApi: dropping stale tool message for undeclared tool_call_id: ' . $tcId);
                    }
                    continue; // skip this message entirely
                }
                $sanitized[] = $msg;
                continue;
            }

            // Any other role (user, system, …): pending tool_calls must be
            // closed first so the next turn arrives on a clean pairing state.
            $flush();
            $sanitized[] = $msg;
        }

        // End-of-history flush guards the synthetic continuation message that
        // buildRawMessages appends after sanitization.
        $flush();

        if (!empty($flushedIds) && defined('WP_DEBUG') && WP_DEBUG) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Sanitizer telemetry.
            error_log('[LolaCore] sanitizeHistoryForApi: injected ' . count($flushedIds) . ' synthetic tool messages for orphan tool_call_ids: ' . implode(', ', $flushedIds));
        }

        // Post-sanitization validation: verify every tool_call has a response.
        // This catches bugs in the flush logic itself.
        $this->validateToolCallPairing($sanitized);

        return $sanitized;
    }

    /**
     * Post-sanitization validation: walks the sanitized history and verifies
     * every assistant tool_call_id has exactly one matching tool response.
     *
     * If any orphan is found after sanitization (should never happen), it logs
     * a critical warning so we catch regression immediately.
     *
     * @param array $sanitized The sanitized history.
     */
    private function validateToolCallPairing(array $sanitized): void
    {
        $openIds = [];

        foreach ($sanitized as $msg) {
            $role = $msg['role'] ?? '';

            if ($role === 'assistant' && !empty($msg['tool_calls'])) {
                foreach ($msg['tool_calls'] as $tc) {
                    $tcId = $tc['id'] ?? '';
                    if ($tcId !== '') {
                        $openIds[$tcId] = $tc['function']['name'] ?? '?';
                    }
                }
            } elseif ($role === 'tool') {
                $tcId = $msg['tool_call_id'] ?? '';
                unset($openIds[$tcId]);
            } elseif (!empty($openIds)) {
                // A non-tool message arrived with open IDs — this means
                // flush() failed to close them, which is a logic bug.
                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Sanitizer validation.
                    error_log('[LolaCore] CRITICAL: sanitizeHistoryForApi validation FAILED — orphan IDs still open before ' . $role . ' turn: ' . implode(', ', array_keys($openIds)));
                }
                $openIds = [];
            }
        }

        if (!empty($openIds) && defined('WP_DEBUG') && WP_DEBUG) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Sanitizer validation.
            error_log('[LolaCore] CRITICAL: sanitizeHistoryForApi validation FAILED — orphan IDs at end of history: ' . implode(', ', array_keys($openIds)));
        }
    }

    /**
     * Build a map of assistant message indices to their reasoning_content.
     * Used to re-inject thinking content into the HTTP request for DeepSeek.
     *
     * DeepSeek V4 rule (confirmed in API docs):
     * - WITH tool_calls: reasoning_content MUST be re-sent or the API returns 400.
     * - WITHOUT tool_calls: reasoning_content is ignored by the model and sending
     *   it wastes tokens. Do NOT include it.
     *
     * @param array $history Conversation history from DB.
     * @return array<int, string> Map of assistant message index => reasoning_content.
     */
    /**
     * Inject the top-level `thinking.type = "disabled"` toggle into outgoing
     * DeepSeek V4 requests, but ONLY when the active provider is DeepSeek AND the model is
     * a V4 variant that supports the toggle. Returns the registered callback
     * so the caller can `remove_filter` it after the request completes.
     *
     * Applies to the current V4 catalog (deepseek-v4-flash, deepseek-v4-pro).
     * Legacy stored IDs (deepseek-chat, deepseek-reasoner) are normalized elsewhere.
     *
     * The thinking toggle itself is applied inside `fixOutgoingRequestBody` so the
     * whole body goes through a single decode/encode pass. A separate filter would
     * re-decode the body (collapsing `{}` to `[]`) and reintroduce the getSiteStatus
     * "[] is not of type object" 400.
     *
     * @param array{provider: string, model: string} $modelConfig Active model.
     * @return bool True when thinking must be disabled for this request.
     */
    private function shouldDisableThinking(array $modelConfig): bool
    {
        if (($modelConfig['provider'] ?? '') !== 'deepseek') {
            return false;
        }
        $modelId = (string) ($modelConfig['model'] ?? '');
        return DeepSeekModelSupport::shouldDisableThinkingForCodegen($modelId);
    }

    private function buildReasoningMap(array $history): array
    {
        $map = [];
        $assistantIndex = 0;

        foreach ($history as $msg) {
            if ($msg['role'] === 'assistant') {
                $reasoning    = $msg['reasoning_content'] ?? '';
                $hasToolCalls = !empty($msg['tool_calls']);
                if (!empty($reasoning) && $hasToolCalls) {
                    $map[$assistantIndex] = $reasoning;
                }
                $assistantIndex++;
            }
        }

        return $map;
    }

    /**
     * Filter callback for `http_request_args`: fixes the outgoing API request
     * body for provider compatibility issues the SDK doesn't handle.
     *
     * Fixes applied:
     * 1. Tool schemas: empty `properties` must be `{}` (object) not `[]` (array).
     * 2. DeepSeek V4: `reasoning_content` must be re-sent ONLY for assistant
     *    messages that had tool_calls (omitting it causes 400; for pure-text
     *    turns it is ignored by the model — buildReasoningMap() enforces this).
     * 3. Assistant message content: converted from array to string format for
     *    broader provider compatibility.
     * 4. Google Gemini: function_call.args must be a JSON object ({}), not an
     *    empty array ([]). PHP json_decode('{}') yields [] which the SDK may
     *    re-serialize as a list and trigger HTTP 400 from Gemini.
     *
     * @param array $args WordPress HTTP request args.
     * @param array<int, string> $reasoningMap Map of assistant index => reasoning.
     * @param bool $disableThinking When true, inject extra_body.thinking=disabled
     *        for DeepSeek V4. Handled here (not in a separate filter) so the single
     *        decode/encode pass below cannot undo the empty-object schema fixes:
     *        json_decode(..., true) collapses every {} into [], and a second filter
     *        re-encoding the body would turn the repaired `properties: {}` back into
     *        `properties: []`, which OpenAI/DeepSeek reject.
     * @return array Modified args.
     */
    private function fixOutgoingRequestBody(array $args, array $reasoningMap, bool $disableThinking = false): array
    {
        if (empty($args['body']) || !is_string($args['body'])) {
            return $args;
        }

        $body = json_decode($args['body'], true);
        if (!is_array($body)) {
            return $args;
        }

        $modified = false;

        // Fix 1: Tool schema empty properties serialization.
        // Different providers use different tool JSON structures:
        //   OpenAI/DeepSeek:  tool.function.parameters.properties
        //   Anthropic (SDK):  tool.custom.input_schema.properties
        // Instead of hardcoding each path, recursively walk the tools array
        // and replace ANY empty 'properties' array with a marker that gets
        // swapped for '{}' in the final JSON string.
        if (isset($body['tools']) && is_array($body['tools'])) {
            $body['tools'] = $this->fixEmptyProperties($body['tools']);
            $modified = true;
        }

        if (isset($body['functions']) && is_array($body['functions'])) {
            $body['functions'] = $this->fixEmptyProperties($body['functions']);
            $modified = true;
        }

        // Fix 2 & 3: Assistant messages (reasoning_content + content format)
        if (isset($body['messages']) && is_array($body['messages'])) {
            foreach ($body['messages'] as &$msg) {
                if (empty($msg['tool_calls']) || !is_array($msg['tool_calls'])) {
                    continue;
                }
                foreach ($msg['tool_calls'] as &$toolCall) {
                    if (($toolCall['function']['arguments'] ?? '') === '[]') {
                        $toolCall['function']['arguments'] = '{}';
                        $modified = true;
                    }
                }
                unset($toolCall);
            }
            unset($msg);

            $assistantIndex = 0;
            foreach ($body['messages'] as &$msg) {
                if (($msg['role'] ?? '') !== 'assistant') {
                    continue;
                }

                // Inject reasoning_content if we have it for this assistant message
                if (isset($reasoningMap[$assistantIndex])) {
                    $msg['reasoning_content'] = $reasoningMap[$assistantIndex];
                    $modified = true;
                }

                // Convert content from array to string for broader compatibility
                if (is_array($msg['content'] ?? null)) {
                    $textParts = [];
                    foreach ($msg['content'] as $part) {
                        if (is_array($part) && ($part['type'] ?? '') === 'text') {
                            $textParts[] = $part['text'] ?? '';
                        }
                    }
                    $msg['content'] = implode("\n", $textParts);
                    $modified = true;
                }

                $assistantIndex++;
            }
            unset($msg);

            // Fix 4: Final safety net — validate tool_call/tool pairing in the
            // serialized body. If the SDK transformation introduced an orphan
            // (or the sanitizer missed one), inject a synthetic tool response
            // directly into the request body. This is the absolute last defense
            // against the DeepSeek/OpenAI "insufficient tool messages" 400.
            $body['messages'] = $this->patchOrphanToolCallsInBody($body['messages']);
            // patchOrphanToolCallsInBody returns the array unchanged if no
            // orphans exist, so we always mark modified to re-encode safely.
            $modified = true;
        }

        // Fix 5: Gemini native contents[] — empty functionCall/function_call args.
        if (isset($body['contents']) && is_array($body['contents'])) {
            foreach ($body['contents'] as &$content) {
                if (!isset($content['parts']) || !is_array($content['parts'])) {
                    continue;
                }
                foreach ($content['parts'] as &$part) {
                    if (!is_array($part)) {
                        continue;
                    }
                    $part = $this->fixFunctionCallPartArgs($part);
                }
                unset($part);
            }
            unset($content);
            $modified = true;
        }

        // Fix 6: DeepSeek V4 thinking toggle. Injected in this same pass — AFTER the
        // empty-properties repair and BEFORE the single encode below — so it never
        // re-collapses the repaired `{}` schemas back into `[]`. (A previous separate
        // priority-11 filter re-decoded the body and reintroduced the getSiteStatus
        // "[] is not of type object" 400.)
        //
        // `thinking` is a TOP-LEVEL DeepSeek request field. `extra_body` is an OpenAI
        // SDK construct the SDK flattens into the top level before sending; DeepSeek's
        // HTTP API does not read a literal `extra_body`, so nesting the toggle there
        // left V4 Pro reasoning until the review call hit cURL 28.
        if ($disableThinking) {
            $body['thinking'] = ['type' => 'disabled'];
            $modified = true;
        }

        if ($modified) {
            $json = wp_json_encode($body);
            $args['body'] = $json;
        }

        return $args;
    }

    /**
     * Caps function declarations for providers with strict payload limits.
     *
     * @param array{abilities: array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration>, sops: string} $intentData
     * @return array{abilities: array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration>, sops: string}
     */
    private function applyProviderToolLimits(array $intentData, string $providerId, string $sessionId = ''): array
    {
        if ($providerId !== 'google') {
            return $intentData;
        }

        $abilities = $intentData['abilities'] ?? [];
        $maxDeclarations = (int) \apply_filters(
            'lolacore_google_max_function_declarations',
            self::GOOGLE_MAX_FUNCTION_DECLARATIONS
        );

        if ($maxDeclarations <= 0 || count($abilities) <= $maxDeclarations) {
            return $intentData;
        }

        $allAbilities = $this->discoverAvailableAbilities();
        $categoryMap = $this->buildCategoryToolMap($allAbilities);
        $totalAbilities = count($allAbilities);
        $isFullToolset = count($abilities) >= max(40, (int) floor($totalAbilities * 0.75));

        $priorityCategories = $sessionId !== ''
            ? (array) \apply_filters(
                'lolacore_google_prune_priority_categories',
                $this->getExpandedCategories($sessionId),
                $sessionId
            )
            : [];

        $priorityToolNames = $this->getToolNamesForCategories($priorityCategories, $categoryMap);

        if ($isFullToolset) {
            $matchedCategories = array_values(array_unique(array_merge(
                ['global', 'meta', 'memory'],
                $priorityCategories
            )));
        } else {
            $matchedCategories = $this->inferMatchedCategoriesFromDeclarations($abilities, $categoryMap);
            $matchedCategories = array_values(array_unique(array_merge(
                ['global', 'meta', 'memory'],
                $priorityCategories,
                $matchedCategories
            )));
        }

        $slimIntent = $this->buildToolset($matchedCategories, $categoryMap, $allAbilities);

        if (count($slimIntent['abilities']) > $maxDeclarations) {
            $slimIntent['abilities'] = $this->pruneFunctionDeclarationsForGoogle(
                $slimIntent['abilities'],
                $maxDeclarations,
                $priorityToolNames
            );
        }

        $pruned = $slimIntent['abilities'];

        self::debugLog(
            '[LolaCore Router] Gemini tool cap: ' . count($abilities) . ' -> ' . count($pruned)
            . ($isFullToolset ? ' (discover-first).' : ' (category slim).')
        );

        $discoverSop = 'SOP (Gemini): Your active tool list is capped for this provider. '
            . 'Use getDiscoveredAbilities(query=...) to find tools by name or category. '
            . 'Call requestAdditionalCategories with the category slug before requestFullToolset.';

        return [
            'abilities' => $pruned,
            'sops' => trim(($intentData['sops'] ?? '') . "\n\n" . $discoverSop),
        ];
    }

    /**
     * @param list<string> $categories
     * @param array<string, array<string, mixed>> $categoryMap
     * @return list<string>
     */
    private function getToolNamesForCategories(array $categories, array $categoryMap): array
    {
        $toolNames = [];
        foreach ($categories as $category) {
            if (!isset($categoryMap[$category]['tools'])) {
                continue;
            }
            $toolNames = array_merge($toolNames, $categoryMap[$category]['tools']);
        }

        return array_values(array_unique($toolNames));
    }

    /**
     * Infers domain categories from the declarations selected by the router.
     *
     * @param array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration> $declarations
     * @param array<string, array<string, mixed>> $categoryMap
     * @return list<string>
     */
    private function inferMatchedCategoriesFromDeclarations(array $declarations, array $categoryMap): array
    {
        $names = [];
        foreach ($declarations as $declaration) {
            $names[] = $declaration->getName();
        }

        $matched = [];
        foreach ($categoryMap as $category => $data) {
            if (in_array($category, ['global', 'meta', 'memory', 'uncategorized'], true)) {
                continue;
            }

            foreach ($data['tools'] ?? [] as $toolName) {
                if (in_array($toolName, $names, true)) {
                    $matched[] = $category;
                    break;
                }
            }
        }

        return $matched;
    }

    /**
     * Keeps essential tools first, then fills remaining slots from the routed set.
     *
     * @param array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration> $declarations
     * @param list<string> $priorityToolNames
     * @return array<int, \WordPress\AiClient\Tools\DTO\FunctionDeclaration>
     */
    private function pruneFunctionDeclarationsForGoogle(
        array $declarations,
        int $maxDeclarations,
        array $priorityToolNames = []
    ): array {
        $essentialNames = $this->getGoogleEssentialToolNames();
        $selected = [];
        $selectedNames = [];

        foreach ($declarations as $declaration) {
            $name = $declaration->getName();
            if (!in_array($name, $essentialNames, true)) {
                continue;
            }
            $selected[] = $declaration;
            $selectedNames[] = $name;
            if (count($selected) >= $maxDeclarations) {
                return $selected;
            }
        }

        foreach ($declarations as $declaration) {
            $name = $declaration->getName();
            if (in_array($name, $selectedNames, true)) {
                continue;
            }
            if (!in_array($name, $priorityToolNames, true)) {
                continue;
            }
            $selected[] = $declaration;
            $selectedNames[] = $name;
            if (count($selected) >= $maxDeclarations) {
                return $selected;
            }
        }

        foreach ($declarations as $declaration) {
            $name = $declaration->getName();
            if (in_array($name, $selectedNames, true)) {
                continue;
            }
            $selected[] = $declaration;
            $selectedNames[] = $name;
            if (count($selected) >= $maxDeclarations) {
                break;
            }
        }

        return $selected;
    }

    /**
     * Tools that must survive Gemini payload pruning.
     *
     * @return list<string>
     */
    private function getGoogleEssentialToolNames(): array
    {
        return [
            'getSiteStatus',
            'getDiscoveredAbilities',
            'requestAdditionalCategories',
            'requestFullToolset',
            'addMemoryFact',
        ];
    }

    /**
     * Encodes tool call arguments for persistence in chat history.
     *
     * Empty parameter objects must serialize as "{}" not "[]" so Gemini and
     * OpenAI-compatible APIs accept history replay on the next turn.
     *
     * @param mixed $args Decoded function arguments from the provider SDK.
     */
    private function encodeFunctionCallArguments(mixed $args): string
    {
        if ($args === null || $args === [] || $args === (object) []) {
            return '{}';
        }

        if (!is_array($args)) {
            if (is_object($args)) {
                $args = (array) $args;
            } else {
                return '{}';
            }
        }

        $encoded = wp_json_encode($args);
        return is_string($encoded) && $encoded !== '' ? $encoded : '{}';
    }

    /**
     * Decodes persisted tool call arguments for FunctionCall DTO construction.
     *
     * Returns null for empty args so Gemini omits the key on replay.
     *
     * @param string $argumentsJson JSON string from chat history metadata.
     * @return array<string, mixed>|null
     */
    private function decodeFunctionCallArguments(string $argumentsJson): ?array
    {
        if ($argumentsJson === '' || $argumentsJson === '{}' || $argumentsJson === '[]') {
            return null;
        }

        $decoded = json_decode($argumentsJson, true);
        if (!is_array($decoded)) {
            return null;
        }

        if ($decoded === []) {
            return null;
        }

        return $decoded;
    }

    /**
     * Normalizes a Gemini content part so function call args are objects, not lists.
     *
     * @param array<string, mixed> $part A single Gemini content part.
     * @return array<string, mixed>
     */
    private function fixFunctionCallPartArgs(array $part): array
    {
        foreach (array( 'function_call', 'functionCall' ) as $callKey) {
            if (!isset($part[$callKey]) || !is_array($part[$callKey])) {
                continue;
            }

            if (isset($part[$callKey]['args']) && is_array($part[$callKey]['args']) && $part[$callKey]['args'] === []) {
                $part[$callKey]['args'] = new \stdClass();
            }
        }

        return $part;
    }

    /**
     * Last-resort safety net: patches orphan tool_call_ids in the serialized
     * HTTP request body messages array.
     *
     * Walks the messages array as it will be sent to the API. For any assistant
     * message with tool_calls, verifies that every tool_call_id has a matching
     * tool response before the next assistant/user message. If not, injects a
     * synthetic tool response inline.
     *
     * This runs AFTER the SDK serialization and all upstream sanitization,
     * catching edge cases where:
     *   - The SDK merges or reorders messages during serialization
     *   - A race condition between DB writes and reads loses a tool message
     *   - The upstream sanitizer has a gap we haven't discovered yet
     *
     * @param array $messages Messages array from the decoded JSON body.
     * @return array Patched messages array.
     */
    private function patchOrphanToolCallsInBody(array $messages): array
    {
        $patched = [];
        $pendingIds = []; // tool_call_id => function name

        foreach ($messages as $msg) {
            $role = $msg['role'] ?? '';

            // When we hit a non-tool message and there are pending IDs,
            // inject synthetic tool responses before this message.
            if ($role !== 'tool' && !empty($pendingIds)) {
                foreach ($pendingIds as $orphanId => $fnName) {
                    $patched[] = [
                        'role' => 'tool',
                        'tool_call_id' => $orphanId,
                        'content' => '{"result":"No result was recorded for this action (recovered by safety net)."}',
                    ];
                }
                if (defined('WP_DEBUG') && WP_DEBUG) {
                    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Safety net telemetry.
                    error_log('[LolaCore] patchOrphanToolCallsInBody: injected ' . count($pendingIds) . ' synthetic tool responses at HTTP layer for: ' . implode(', ', array_keys($pendingIds)));
                }
                $pendingIds = [];
            }

            if ($role === 'assistant' && !empty($msg['tool_calls'])) {
                foreach ($msg['tool_calls'] as $tc) {
                    $tcId = $tc['id'] ?? '';
                    if ($tcId !== '') {
                        $pendingIds[$tcId] = $tc['function']['name'] ?? '?';
                    }
                }
            }

            if ($role === 'tool') {
                $tcId = $msg['tool_call_id'] ?? '';
                unset($pendingIds[$tcId]);
            }

            $patched[] = $msg;
        }

        // Flush any remaining orphans at end of messages
        if (!empty($pendingIds)) {
            foreach ($pendingIds as $orphanId => $fnName) {
                $patched[] = [
                    'role' => 'tool',
                    'tool_call_id' => $orphanId,
                    'content' => '{"result":"No result was recorded for this action (recovered by safety net)."}',
                ];
            }
            if (defined('WP_DEBUG') && WP_DEBUG) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Safety net telemetry.
                error_log('[LolaCore] patchOrphanToolCallsInBody: injected ' . count($pendingIds) . ' synthetic tool responses at end of messages for: ' . implode(', ', array_keys($pendingIds)));
            }
        }

        return $patched;
    }

    /**
     * Recursively walk an array and replace any 'properties' key whose
     * value is an empty array with the '__EMPTY_OBJECT__' marker string.
     *
     * This ensures that json_encode outputs {} instead of [] for empty
     * properties, regardless of where they appear in the tool schema tree.
     *
     * @param array $data The array to walk.
     * @return array The modified array.
     */
    private function fixEmptyProperties(array $data): array
    {
        foreach ($data as $key => &$value) {
            if (in_array($key, array( 'parameters', 'input_schema' ), true) && is_array($value) && $value === array()) {
                $value = array(
                    'type'       => 'object',
                    'properties' => new \stdClass(),
                );
                continue;
            }
            if ($key === 'properties' && is_array($value) && empty($value)) {
                $value = new \stdClass();
                continue;
            }
            if (is_array($value)) {
                $value = $this->fixEmptyProperties($value);
            }
        }
        unset($value);
        return $data;
    }

    /**
     * Builds and caches a map of categories to their associated tools and keywords.
     * Uses MD5 hash of tool keys for cache invalidation.
     *
     * @param array $allAbilities All discovered abilities.
     * @return array Map of category => ['tools' => [...], 'keywords' => [...]]
     */
    private function buildCategoryToolMap(array $allAbilities): array
    {
        if (empty($allAbilities)) {
            return [];
        }

        $keys = array_keys($allAbilities);
        sort($keys);
        // Include plugin version so that updates invalidate stale cache
        $version = defined('LOLACORE_VERSION') ? LOLACORE_VERSION : '0';
        $hash = md5($version . '|' . serialize($keys));
        $transientKey = 'lolacore_catmap_' . $hash;

        $cachedMap = \get_transient($transientKey);
        if ($cachedMap !== false) {
            return $cachedMap;
        }

        $map = [];
        foreach ($allAbilities as $name => $ability) {
            $category = $ability['category'] ?? $this->inferCategoryFromName($name);
            if (!isset($map[$category])) {
                $map[$category] = ['tools' => [], 'keywords' => ['exact' => [], 'stem' => []]];
            }

            $map[$category]['tools'][] = $name;

            $explicitKeywords = $ability['keywords'] ?? ['exact' => [], 'stem' => []];
            if (empty($explicitKeywords) || !isset($explicitKeywords['exact'])) {
                $explicitKeywords = ['exact' => (is_array($explicitKeywords) ? $explicitKeywords : []), 'stem' => []];
            }
            
            $inferredExact = $this->extractKeywordsFromDescription($ability['description'] ?? '');
            
            $mergedExact = array_unique(array_merge($map[$category]['keywords']['exact'], $explicitKeywords['exact'], $inferredExact));
            $mergedStem = array_unique(array_merge($map[$category]['keywords']['stem'], $explicitKeywords['stem']));
            
            $map[$category]['keywords']['exact'] = array_values($mergedExact);
            $map[$category]['keywords']['stem'] = array_values($mergedStem);
        }

        // Exclude disabled domains
        $disabledDomainsJson = \LolaCore\Admin\Settings::getConfig('disabled_domains', '[]');
        $disabledDomains = json_decode($disabledDomainsJson, true);
        if (is_array($disabledDomains) && !empty($disabledDomains)) {
            $disabledCategories = [];
            foreach ($disabledDomains as $domainSlug) {
                if (isset(\LolaCore\Admin\Settings::DOMAIN_MAP[$domainSlug])) {
                    $disabledCategories = array_merge($disabledCategories, \LolaCore\Admin\Settings::DOMAIN_MAP[$domainSlug]);
                }
            }
            
            $addonDomains = apply_filters('lolacore_addon_domains', []);
            foreach ($disabledDomains as $domainSlug) {
                if (isset($addonDomains[$domainSlug])) {
                    $disabledCategories[] = $addonDomains[$domainSlug]['category'] ?? $domainSlug;
                }
            }

            foreach ($disabledCategories as $catToDisable) {
                if (isset($map[$catToDisable])) {
                    unset($map[$catToDisable]);
                }
            }
        }

        \set_transient($transientKey, $map, HOUR_IN_SECONDS);
        return $map;
    }

    /**
     * Infers a fallback category based on the tool name.
     */
    private function inferCategoryFromName(string $name): string
    {
        $nameLower = strtolower($name);
        if (strpos($nameLower, 'wc') === 0 || strpos($nameLower, 'woo') !== false) {
            return 'woocommerce';
        }
        if (strpos($nameLower, 'plugin') !== false) {
            return 'plugins';
        }
        if (strpos($nameLower, 'theme') !== false) {
            return 'themes';
        }
        if (strpos($nameLower, 'user') !== false) {
            return 'users';
        }
        if (strpos($nameLower, 'post') !== false || strpos($nameLower, 'content') !== false) {
            return 'content';
        }
        return 'uncategorized';
    }

    /**
     * Extracts keywords from a description by removing common stopwords.
     */
    private function extractKeywordsFromDescription(string $description): array
    {
        $text = strtolower(preg_replace('/[^a-zA-ZÃ¡Ã©Ã­Ã³ÃºÃ¼Ã±]/u', ' ', $description));
        $words = array_filter(explode(' ', $text));
        
        $stopwords = [
            'the','is','in','at','of','on','and','a','to','for','with','it','this','that','from','by','as',
            'el','la','los','las','un','una','en','de','por','para','con','y','o','es','su','al','del',
            'returns','retrieves','updates','creates','deletes','manages','gets','sets','lists',
            'devuelve','obtiene','actualiza','crea','elimina','gestiona','lista','you','must','call',
            'tool','execute','action','system','automatically','pause','ask','user','permission'
        ];
        
        $keywords = [];
        foreach ($words as $word) {
            if (mb_strlen($word) > 2 && !in_array($word, $stopwords, true)) {
                $keywords[] = $word;
            }
        }
        
        return array_values(array_unique($keywords));
    }

    /**
     * WP_DEBUG-only router telemetry. Keeps debug.log concise on large tool registries.
     */
    private static function debugLog(string $message): void
    {
        if (!defined('WP_DEBUG') || !WP_DEBUG || !function_exists('error_log')) {
            return;
        }

        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Router debug telemetry.
        error_log($message);
    }

    /**
     * @param array<int, string> $items
     */
    private static function summarizeListForLog(array $items, int $maxShown = 5): string
    {
        $items = array_values($items);
        if ($items === []) {
            return '(none)';
        }

        if (count($items) <= $maxShown) {
            return implode(', ', $items);
        }

        $head = array_slice($items, 0, $maxShown);

        return implode(', ', $head) . ' (+' . (count($items) - $maxShown) . ' more)';
    }

    /**
     * @param array<string, array<string, mixed>> $abilities
     */
    private static function summarizeAbilityRegistry(array $abilities): string
    {
        $total = count($abilities);
        if ($total === 0) {
            return '0 tools';
        }

        $categoryCounts = [];
        foreach ($abilities as $ability) {
            $category = isset($ability['category']) ? (string) $ability['category'] : 'unknown';
            $categoryCounts[$category] = ($categoryCounts[$category] ?? 0) + 1;
        }

        ksort($categoryCounts);
        $parts = [];
        foreach ($categoryCounts as $category => $count) {
            $parts[] = $category . ':' . $count;
        }

        return $total . ' tools in ' . count($categoryCounts) . ' categories (' . self::summarizeListForLog($parts, 6) . ')';
    }
}