'use strict';

const conf = require('../../conf');
const { sleep } = require('../../utils');
const { ProviderFactory } = require('./providers');

function compilePrompt(templateStr) {
	return new Function('name', 'interval', 'currentPlayers', 'toolsDocs', 'memoryContext', 'timeInfo', 'custom',
		'return `' + templateStr + '`');
}
const memoryDB = require('./memory-db');
const { buildFleetTools } = require('./fleet-tools');

class AiManager {
    constructor() {
        this._provider = null;
        this._pollTimer = null;
        this._polling = false;
        this._faceBot = null;
        this._messages = [];
        this._allTools = [];
        this._memoryDB = memoryDB;
        this._active = false;
        this._config = null;
        this._consecutiveFailures = 0;
        this._backoffUntil = 0;
        this._lastSentMessages = [];
        this._messageListener = null;
        this._tradeWindow = null;
        this._tradeWindowState = null;
        this._expectingTradeWindow = false;
    }

    /**
     * Initialize the manager on a face bot. Idempotent — subsequent calls
     * with different bots are no-ops. Call shutdown() first to transfer.
     */
    async init(faceBot, configOverride) {
        if (this._active) {
            if (this._faceBot === faceBot) {
                console.log('AiManager: already running on', faceBot.name);
                return;
            }
            console.log('AiManager: already running on', this._faceBot.name,
                        '— shutdown first before re-initting on', faceBot.name);
            return;
        }

        this._faceBot = faceBot;
        this._config = { ...conf.ai, ...configOverride };

        // Override runtime-tunable AI settings from DB (config file provides defaults)
        const settings = require('../settings/manager');
        this._config.provider = settings.get('ai.provider');
        this._config.model = settings.get('ai.model');
        this._config.temperature = settings.get('ai.temperature');
        this._config.topP = settings.get('ai.topP');
        this._config.topK = settings.get('ai.topK');
        this._config.interval = settings.get('ai.interval');
        this._config.timeout = settings.get('ai.timeout');
        this._config.promptName = settings.get('ai.promptName');
        this._config.enableNativeTools = settings.get('ai.enableNativeTools');
        this._config.baseUrl = settings.get('ai.baseUrl');
        this._config.key = settings.get('ai.key');
        this._config.faceBot = settings.get('ai.faceBot');
        this._config.storageBot = settings.get('ai.storageBot');

        // Initialize memory DB (first init creates DB, subsequent are no-ops)
        await this._memoryDB.initialize('./storage/ai-memory.db', faceBot.name);

        // Build fleet-wide tools
        this._allTools = buildFleetTools(this._config, this._memoryDB);
        console.log(`AiManager: ${this._allTools.length} fleet tools built`);

        // Create the ONE provider
        const prompt = await this._buildPrompt();
        this._provider = ProviderFactory.create({
            ...this._config,
            prompt,
        });

        // Set tool schemas if provider supports native function calling AND enabled in config.
        // Most 9B models crash on native tool schemas (Ollama 500: "XML syntax error").
        // When disabled, the LLM sees tools described in the prompt text and we parse
        // tool calls from the JSON response via _extractToolCalls fallback.
        if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
            this._provider.setTools(this._getToolsSchema());
            console.log('AiManager: native tool schemas set on provider');
        } else if (this._config.enableNativeTools) {
            console.log('AiManager: provider does not support tools, using text-based fallback');
        } else {
            console.log('AiManager: native tools disabled via config, using text-based tool descriptions');
        }

        await this._provider.start();
        console.log(`AiManager: provider started (${this._config.provider}, model=${this._config.model})`);

        // Set up message listener on the face bot
        this._setupMessageListener();

        // Start poll timer
        this._active = true;
        this._startPolling();
        console.log(`AiManager: running on ${faceBot.name}, interval=${this._config.interval}s`);
    }

    /**
     * Reload the system prompt without restarting the provider.
     * Used by the .ai chat command to change personality on the fly.
     */
    async reloadPrompt(promptName, prompCustom) {
        if (!this._active || !this._provider) return false;
        if (promptName !== undefined) this._config.promptName = promptName;
        if (prompCustom !== undefined) this._config.prompCustom = prompCustom;
        const prompt = await this._buildPrompt();
        if (typeof this._provider.setPrompt === 'function') {
            this._provider.setPrompt(prompt);
        } else {
            await this._provider.close();
            this._provider = ProviderFactory.create({ ...this._config, prompt });
            if (this._provider.supportsTools && this._provider.supportsTools() && this._config.enableNativeTools) {
                this._provider.setTools(this._getToolsSchema());
            }
            await this._provider.start();
        }
        console.log(`AiManager: prompt reloaded — ${this._config.promptName}`);
        return true;
    }

    async shutdown() {
        this._active = false;

        if (this._pollTimer) {
            clearInterval(this._pollTimer);
            this._pollTimer = null;
        }

        if (this._messageListener) {
            this._messageListener();
            this._messageListener = null;
        }

        if (this._provider) {
            try { await this._provider.close(); } catch (e) { /* ignore */ }
            this._provider = null;
        }

        this._messages = [];
        this._faceBot = null;
        console.log('AiManager: shut down');
    }

    get isActive() { return this._active; }
    get faceBotName() { return this._faceBot ? this._faceBot.name : null; }

    /**
     * Called by SettingsManager when an ai.* setting is changed.
     * Updates the in-memory config immediately. Provider re-creation
     * happens on the next poll cycle if model/provider/key changed.
     */
    onSettingChanged(key, newValue) {
        if (!this._config) return;
        const shortKey = key.replace('ai.', '');
        console.log(`AiManager: setting changed — ${key} = ${JSON.stringify(newValue)}`);
        this._config[shortKey] = newValue;

        if (['promptName', 'prompCustom', 'prompts'].includes(shortKey)) {
            this.reloadPrompt();
        }
    }

    /**
     * Let other bots/plugins inject a system notification into the face bot's
     * message queue so the LLM knows about trade completions, errors, etc.
     * Call this whenever a fleet bot does something the LLM should know about.
     */
    notifySystemEvent(text) {
        if (!this._active) return;
        const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
        this._messages.push({
            type: 'system',
            text: `[SYSTEM] ${text}`,
            timestamp,
            timeAgo: this._getTimeAgo(timestamp),
        });
        console.log(`AiManager: system event — ${text}`);
    }

    // ========================================
    // Message listener
    // ========================================

    _setupMessageListener() {
        this._messageListener = this._faceBot.on('message', (message, type) => {
            const msgText = message.toString();

            // Log ALL messages the face bot receives (for debugging)
            const cleanText = msgText.replace(/\n/g, '\\n').substring(0, 300);
            console.log(`[MC→${this._faceBot.name}] (${type || 'unknown'}) ${cleanText}`);

            if (type === 'game_info') return;

            // Skip messages from the face bot itself
            if (msgText.startsWith('<')) {
                const firstBracket = msgText.split('>')[0];
                const userMatch = firstBracket.match(/^<\[?.*?\]?\s*(\w+)>$/);
                if (userMatch) {
                    const speakerName = userMatch[1];
                    if (speakerName === this._faceBot.bot.entity.username) return;
                }
            }

            const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
            this._messages.push({
                type: 'message',
                text: msgText,
                timestamp,
                timeAgo: this._getTimeAgo(timestamp),
            });
        });

        // Monitor trade windows for feedback loop
        this._faceBot.bot.on('windowOpen', (window) => {
            if (!this._tradeWindow && this._expectingTradeWindow && window.slots && window.slots.length >= 54) {
                this._expectingTradeWindow = false;
                console.log('AiManager: trade window detected, capturing state');
                this._setupTradeWindow(window);
            }
        });
    }

    // ========================================
    // Polling
    // ========================================

    _startPolling() {
        const intervalMs = (this._config.interval || 10) * 1000;
        this._pollTimer = setInterval(() => this._pollCycle(), intervalMs);
    }

    async _pollCycle() {
        if (!this._active || Date.now() < this._backoffUntil) return;
        if (this._polling) return;
        this._polling = true;

        try {
            // Snapshot and reset
            const currentMessages = [...this._messages];
            this._messages = [];

            const hasData = currentMessages.some(m => typeof m === 'object' && m.text);
            const hasTrade = !!this._tradeWindowState;
            if (!hasData && !hasTrade) return;

            // Build request data
            const requestData = {
                botName: this._faceBot.name,
                messages: currentMessages,
                currentTime: new Date().toLocaleString('sv-SE'),
                timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
                tradeWindow: this._tradeWindowState,
            };

            // Refresh the system prompt with current memories and online players.
            // Ollama re-sends the system prompt on every request, so new
            // memories take effect immediately. (Gemini bakes the prompt into
            // session history and only picks this up on provider restart.)
            try {
                if (typeof this._provider.setPrompt === 'function') {
                    this._provider.setPrompt(await this._buildPrompt());
                }
            } catch (e) {
                console.log('AiManager: prompt refresh failed:', e.message);
            }

            console.log(`AiManager: poll cycle — ${currentMessages.length} messages`);
            let result;
            try {
                result = await this._provider.chat(JSON.stringify(requestData));
            } catch (error) {
                console.log('AiManager: API error:', error.message);
                this._consecutiveFailures++;
                const backoffMs = Math.min(1000 * Math.pow(2, this._consecutiveFailures), 30000);
                this._backoffUntil = Date.now() + backoffMs;
                console.log(`AiManager: backoff ${backoffMs}ms (#${this._consecutiveFailures})`);
                return;
            }

            this._consecutiveFailures = 0;
            this._backoffUntil = 0;

            // Check for tool calls
            const requestingPlayer = this._getLastSpeaker(currentMessages);
            const toolCalls = this._extractToolCalls(result);

            if (toolCalls && toolCalls.length > 0) {
                const seen = new Set();
                const uniqueCalls = toolCalls.filter(tc => {
                    const key = `${tc.name}:${JSON.stringify(tc.args || {})}`;
                    if (seen.has(key)) { console.log('AiManager: deduplicating tool call:', key); return false; }
                    seen.add(key);
                    return true;
                });
                console.log(`AiManager: ${uniqueCalls.length} tool calls — ${uniqueCalls.map(c => c.name).join(', ')}`);

                // Execute tools
                const toolResults = [];
                for (const tc of uniqueCalls) {
                    try {
                        const toolResult = await this._executeTool(tc.name, tc.args || {}, requestingPlayer);
                        toolResults.push({ name: tc.name, result: toolResult, success: true });
                    } catch (execError) {
                        console.error('AiManager: tool execution error:', execError);
                        toolResults.push({ name: tc.name, error: execError.message, success: false });
                    }
                }

                // Follow-up: send tool results back to LLM for natural language response
                if (toolResults.length > 0) {
                    // Build current fleet status for context
                    const fleetStatus = this._getLiveFleetStatus();

                    const followupMsg = JSON.stringify({
                        toolResults,
                        fleetStatus,
                        tradeWindow: this._tradeWindowState,
                        instruction: `Tool results and fleet status above.
- Fleet bots listed as ONLINE can be interacted with directly by players via /trade.
- If ${this._config.storageBot} is ONLINE, the player can trade with them without you doing anything.
- Reply with ONE brief message (max 150 chars) as a plain JSON array: [{"text":"...","delay":0}].
- Be natural and casual. If the tool failed, apologize naturally. NEVER mention "tool", "bot", or "AI".`
                    });

                    try {
                        const followupResult = await this._provider.chat(followupMsg);
                        const responseText = this._provider.getResponse(followupResult);
                        await this._processResponse(responseText);
                    } catch (e) {
                        console.error('AiManager: follow-up chat error:', e);
                    }
                }
                return;
            }

            // No tool calls — normal chat response
            const responseText = this._provider.getResponse(result);
            await this._processResponse(responseText);

        } catch (error) {
            console.error('AiManager: poll cycle error:', error);
        } finally {
            this._polling = false;
        }
    }

    // ========================================
    // Tool execution
    // ========================================

    async _executeTool(toolName, params, from) {
        const tool = this._allTools.find(t => t.name === toolName);
        if (!tool) throw new Error(`Unknown tool: ${toolName}`);
        console.log(`AiManager: executing ${toolName}`, params);
        return tool.execute(params, from);
    }

    _extractToolCalls(result) {
        // Gemini native function calls
        if (result.response && typeof result.response.functionCalls === 'function') {
            const calls = result.response.functionCalls();
            if (calls && calls.length > 0) {
                return calls.map(call => ({ name: call.name, args: call.args }));
            }
        }

        // Ollama native tool_calls
        if (result.tool_calls && result.tool_calls.length > 0) {
            return result.tool_calls.map(tc => ({
                name: tc.name || tc.function?.name,
                args: tc.args || tc.arguments || tc.function?.arguments || {},
            }));
        }

        // Fallback: parse JSON from response text
        const responseText = result.response ? result.response.text() : null;
        if (responseText) {
            try {
                const parsed = JSON.parse(responseText);
                if (parsed.tool_call) {
                    return [{ name: parsed.tool_call.name, args: parsed.tool_call.args || parsed.tool_call.arguments || {} }];
                }
                if (Array.isArray(parsed.tool_calls)) {
                    return parsed.tool_calls.map(tc => ({
                        name: tc.name || tc.function?.name,
                        args: tc.args || tc.arguments || tc.function?.arguments || {},
                    }));
                }
            } catch (e) {
                // Not JSON, no tool calls
            }

            // Final fallback: detect tool names embedded in chat text
            // This catches models that output [{"text":"storage_find stone","delay":0}]
            const detectedCalls = this._detectTextToolCalls(responseText);
            if (detectedCalls) return detectedCalls;
        }

        return null;
    }

    /**
     * Detect when the LLM embeds tool names in chat message text instead of
     * using the proper tool_call format. Parses JSON arrays like
     * [{"text":"storage_find stone","delay":0}] and converts to tool calls.
     */
    _detectTextToolCalls(responseText) {
        try {
            const parsed = JSON.parse(responseText);

            // Handle JSON array of chat messages: [{"text":"storage_find stone","delay":0}]
            if (Array.isArray(parsed)) {
                const toolNames = this._allTools.map(t => t.name);
                const toolCalls = [];

                for (const msg of parsed) {
                    const text = (msg.text || '').trim();
                    if (!text || text === '_' || text.length < 3) continue;

                    for (const toolName of toolNames) {
                        if (text === toolName || text.startsWith(toolName + ' ')) {
                            const argsStr = text.substring(toolName.length).trim();
                            const args = this._parseTextArgs(argsStr, toolName);
                            console.log(`AiManager: detected text tool call in chat array: ${toolName}`, args);
                            toolCalls.push({ name: toolName, args });
                            break;
                        }
                    }
                }

                return toolCalls.length > 0 ? toolCalls : null;
            }
        } catch (e) {
            // Not JSON — check if the raw text starts with a known tool name
            const text = (responseText || '').trim();
            if (text.length < 3 || text.startsWith('{') || text.startsWith('[')) return null;

            const toolNames = this._allTools.map(t => t.name);
            for (const toolName of toolNames) {
                if (text === toolName || text.startsWith(toolName + ' ')) {
                    const argsStr = text.substring(toolName.length).trim();
                    const args = this._parseTextArgs(argsStr, toolName);
                    console.log(`AiManager: detected text tool call in raw text: ${toolName}`, args);
                    return [{ name: toolName, args }];
                }
            }
        }
        return null;
    }

    /**
     * Parse space-separated text args into a structured object based on the
     * tool's parameter definitions. Handles formats like:
     *   "stone" → {itemName: "stone"}
     *   "stone 64 wmantly" → {itemName: "stone", count: 64, playerName: "wmantly"}
     */
    _parseTextArgs(argsStr, toolName) {
        const tool = this._allTools.find(t => t.name === toolName);
        if (!tool || !tool.parameters || tool.parameters.length === 0) return {};

        const parts = argsStr.split(/\s+/);
        const params = tool.parameters;
        const args = {};

        for (let i = 0; i < Math.min(parts.length, params.length); i++) {
            const value = parts[i];
            const paramType = params[i].type || 'string';

            if (paramType === 'number') {
                const num = parseInt(value, 10);
                args[params[i].name] = isNaN(num) ? value : num;
            } else {
                args[params[i].name] = value;
            }
        }

        return args;
    }

    _getToolsSchema() {
        return this._allTools.map(t => ({
            name: t.name,
            description: t.description,
            parameters: {
                type: 'object',
                properties: (t.parameters || []).reduce((acc, p) => {
                    acc[p.name] = { type: p.type || 'string', description: p.description || '' };
                    return acc;
                }, {}),
                required: (t.parameters || []).filter(p => p.required).map(p => p.name),
            },
        }));
    }

    // ========================================
    // Prompt building
    // ========================================

    async _buildPrompt() {
        const settings = require('../settings/manager');
        const allPrompts = settings.get('ai.prompts') || {};

        const promptName = this._config.promptName || 'asshole';
        let templateStr = allPrompts[promptName];

        let promptFn;
        if (templateStr) {
            try {
                promptFn = compilePrompt(templateStr);
            } catch (e) {
                console.warn(`AiManager: failed to compile prompt '${promptName}', falling back to config:`, e.message);
                promptFn = conf.ai.prompts[promptName];
            }
        } else {
            console.warn(`AiManager: prompt '${promptName}' not found in DB or config, falling back to asshole`);
            templateStr = allPrompts['asshole'];
            if (templateStr) {
                try { promptFn = compilePrompt(templateStr); }
                catch (e) { promptFn = conf.ai.prompts['asshole']; }
            } else {
                promptFn = conf.ai.prompts['asshole'];
            }
            this._config.promptName = 'asshole';
        }

        if (!promptFn) {
            console.warn(`AiManager: prompt '${promptName}' not found, falling back to asshole`);
            return conf.ai.prompts['asshole'](
                this._faceBot.bot.entity.username,
                this._config.interval,
                '', '', '', '', '',
            );
        }

        const fleetContext = this._getFleetContext();
        const rawToolsDocs = this._getToolsDocumentation();
        const toolsDocs = fleetContext + (rawToolsDocs ? '\n' + rawToolsDocs : '');

        let currentPlayers = '';
        try {
            const players = this._faceBot.getPlayers();
            currentPlayers = Object.values(players)
                .map(p => `<[${p.lvl}] ${p.username}>`)
                .join('\n');
        } catch (e) {
            currentPlayers = '(players unavailable)';
        }

        const timeInfo = this._getCurrentTimeInfo();
        const memoryContext = await this._getMemoryContext();

        return promptFn(
            this._faceBot.bot.entity.username,
            this._config.interval,
            currentPlayers,
            toolsDocs,
            memoryContext,
            timeInfo,
            this._config.prompCustom || '',
        );
    }

    /**
     * Assemble the memory block for the system prompt: bot directives,
     * general memories, and stored facts about every player currently online.
     */
    async _getMemoryContext() {
        let context = '';
        try {
            const general = await this._memoryDB.getMemoryContext(this._faceBot.name);
            if (general) context += general;

            // Player memories for everyone online right now (skip fleet bots)
            const { CJbot } = require('../../model/minecraft');
            const botUsernames = new Set(
                Object.values(CJbot.bots)
                    .map(b => b.bot?.entity?.username)
                    .filter(Boolean)
            );
            const onlinePlayers = Object.keys(this._faceBot.bot.players || {})
                .filter(name => !botUsernames.has(name));

            const playerMems = await this._memoryDB.getPlayerMemoriesForPrompt(onlinePlayers);
            if (playerMems) {
                context += 'WHAT YOU KNOW ABOUT PLAYERS CURRENTLY ONLINE (from past conversations — use it naturally, do not recite it):\n'
                    + playerMems + '\n';
            }
        } catch (e) {
            console.log('AiManager: memory context build failed:', e.message);
        }
        return context.trim();
    }

    _getFleetContext() {
        const { CJbot } = require('../../model/minecraft');
        const botNames = Object.keys(CJbot.bots);
        const onlineBots = botNames.filter(name => CJbot.bots[name].isReady);
        const offlineBots = botNames.filter(name => !CJbot.bots[name].isReady);

        const fleetBots = botNames.map(name => {
            const b = CJbot.bots[name];
            const status = b.isReady ? 'online' : 'offline (on-demand)';
            if (name === this._faceBot.name) return `- ${name} (YOU — always online)`;
            if (name === this._config.storageBot) return `- ${name} (storage, ${status})`;
            return `- ${name} (${status})`;
        }).join('\n');

        const nativeToolsNote = this._config.enableNativeTools ? '' : `
TOOL CALL FORMAT (CRITICAL — you MUST use this exact format):
When you need to call a tool, respond with a SINGLE JSON object:
{"tool_call": {"name": "tool_name", "args": {"param1": "value1", "param2": "value2"}}}

Example: to search for stone, respond with:
{"tool_call": {"name": "storage_find", "args": {"itemName": "stone"}}}

NEVER put tool names in chat text messages. NEVER type "storage_find stone" as a chat message.
Use the {"tool_call": ...} format above instead.

For normal chat (no tools needed), respond with the array format:
[{"text": "your message", "delay": 0}]
`;

        return `FLEET BOTS YOU CAN CONTROL:
${fleetBots}
${nativeToolsNote}
CRITICAL: Every other name in chat is a PLAYER. Do NOT call bot_activate, bot_status, or any fleet tool on player names. Only use those tools for the fleet bots listed above.

MEMORY (IMPORTANT — this is how you remember people between sessions):
- When a player tells you a fact worth keeping — about themselves OR another player (base location, current project, who they trust, preferences, grudges, play schedule) — call remember_player RIGHT THEN. Don't wait to be asked to remember.
- Keys are short snake_case: base_location, current_project, trust, friend_of, notes, warned_about.
- One fact per key. Writing to an existing key overwrites it — use that to update stale facts.
- What you already know about online players is in your context under "WHAT YOU KNOW ABOUT PLAYERS". For players not online, use recall_player.
- If a fact turns out wrong, call forget_player to remove that key.
- Standing orders from owners ("always greet new players", "stop mentioning X") are directives: use set_directive.

CHARACTER RULES (YOU WILL BE REMOVED IF YOU VIOLATE):
- You are a human player. NEVER say "bot", "AI", "tool", "fleet", or "activate."
- NEVER type server commands (/msg, /trade, /invite, /help, /w, /tell) in public chat. If you need to message someone, use your tools instead.
- NEVER suggest commands to other players ("type /msg X", "do /trade Y"). That's robotic.
- Keep responses under 120 chars. Casual, brief, natural.
- ez is "a friend who handles my storage" if asked. Not a bot.`;
    }

    /**
     * Lightweight fleet status snapshot for post-tool-execution follow-up.
     * Tells the LLM which bots are online and where they are so it doesn't
     * say incorrect things like "he can't find you" when the bot is already there.
     */
    _getLiveFleetStatus() {
        const { CJbot } = require('../../model/minecraft');
        const statuses = [];
        for (const [name, bot] of Object.entries(CJbot.bots)) {
            if (bot.isReady && bot.bot?.entity) {
                const e = bot.bot.entity;
                statuses.push({ bot: name, status: 'ONLINE', pos: `(${Math.round(e.position.x)},${Math.round(e.position.y)},${Math.round(e.position.z)})` });
            } else {
                statuses.push({ bot: name, status: 'OFFLINE' });
            }
        }
        return statuses;
    }

    _getToolsDocumentation() {
        if (this._allTools.length === 0) return '';

        const byCategory = {};
        for (const t of this._allTools) {
            const cat = t.category || 'other';
            if (!byCategory[cat]) byCategory[cat] = [];
            byCategory[cat].push(t);
        }

        let doc = '';
        for (const [cat, tools] of Object.entries(byCategory)) {
            doc += `## ${cat}\n`;
            for (const t of tools) {
                doc += `- **${t.name}**: ${t.description}`;
                if (t.parameters && t.parameters.length > 0) {
                    doc += ` (params: ${t.parameters.map(p => p.name).join(', ')})`;
                }
                doc += '\n';
            }
            doc += '\n';
        }
        return doc;
    }

    // ========================================
    // Response processing
    // ========================================

    async _processResponse(responseText) {
        if (!responseText) return;

        const cleaned = this._stripMarkdownFences(responseText);

        // Try JSON array [{text, delay}]
        try {
            const parsed = JSON.parse(cleaned);
            if (Array.isArray(parsed)) {
                for (const message of parsed) {
                    const msgText = (message.text || '').trim();
                    console.log('AiManager: toSay delay=', message.delay, msgText);

                    if (!msgText || msgText === '_' || msgText.match(/^[-_]+$/)) continue;

                    const dedupeKey = msgText.toLowerCase();
                    if (this._lastSentMessages.includes(dedupeKey)) {
                        console.log('AiManager: skipping duplicate:', msgText);
                        continue;
                    }
                    if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
                    this._lastSentMessages.push(dedupeKey);

                    await this._faceBot.sayAiSafe(msgText);
                }
                return;
            }
        } catch (jsonError) {
            // Not JSON, fall through
        }

        // Plain text fallback
        const text = cleaned.trim();
        if (!text || text === '_' || text === '___' || text.match(/^[-_]+$/)) return;
        if (text.startsWith('{') || text.startsWith('```')) {
            console.log('AiManager: skipping raw JSON/code block');
            return;
        }

        const dedupeKey = text.toLowerCase();
        if (this._lastSentMessages.includes(dedupeKey)) {
            console.log('AiManager: skipping duplicate plain-text:', text);
            return;
        }
        if (this._lastSentMessages.length >= 50) this._lastSentMessages.shift();
        this._lastSentMessages.push(dedupeKey);

        await this._faceBot.sayAiSafe(text);
    }

    _stripMarkdownFences(text) {
        if (!text || typeof text !== 'string') return text;
        let cleaned = text.trim();
        cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '');
        cleaned = cleaned.replace(/\n?```\s*$/, '');
        return cleaned.trim();
    }

    // ========================================
    // Trade window
    // ========================================

    _setupTradeWindow(window) {
        this._tradeWindow = window;
        this._tradeWindowState = this._captureTradeState(window);

        const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];
        for (const slot of customerSlots) {
            window.on(`updateSlot:${slot}`, () => {
                if (this._tradeWindow === window) {
                    this._tradeWindowState = this._captureTradeState(window);
                }
            });
        }
        window.on('updateSlot:53', () => {
            if (this._tradeWindow === window) {
                this._tradeWindowState = this._captureTradeState(window);
            }
        });
        window.on('updateSlot:37', () => {
            if (this._tradeWindow === window) {
                this._tradeWindowState = this._captureTradeState(window);
            }
        });

        this._faceBot.bot.once('windowClose', () => {
            if (this._tradeWindow === window) {
                this._tradeWindow = null;
                this._tradeWindowState = null;
            }
        });
    }

    _captureTradeState(window) {
        const botSlots = [0, 1, 2, 3, 9, 10, 11, 12, 18, 19, 20, 21];
        const customerSlots = [5, 6, 7, 8, 14, 15, 16, 17, 23, 24, 25, 26];

        const readSlots = (slots) => slots
            .map(s => window.slots[s])
            .filter(Boolean)
            .map(item => ({ name: item.name, count: item.count }));

        return {
            botItems: readSlots(botSlots),
            customerItems: readSlots(customerSlots),
            customerConfirmed: !!(window.slots[53] && window.slots[53].name === 'lime_dye'),
            botConfirmed: !!(window.slots[37] && window.slots[37].name === 'lime_dye'),
        };
    }

    // ========================================
    // Helpers
    // ========================================

    _getLastSpeaker(messages) {
        if (!messages || !Array.isArray(messages)) return 'ai';
        for (let i = messages.length - 1; i >= 0; i--) {
            const msg = messages[i];
            if (!msg || msg.type !== 'message') continue;
            const text = msg.text || '';
            const match = text.match(/^<\[?.*?\]?\s+(\w+)>/);
            if (match && match[1] && match[1] !== this._faceBot.bot.entity.username) {
                return match[1];
            }
        }
        return 'ai';
    }

    _getTimeAgo(timestamp) {
        const now = new Date();
        const past = new Date(timestamp);
        const diffMs = now - past;
        const diffSecs = Math.floor(diffMs / 1000);
        const diffMins = Math.floor(diffSecs / 60);
        const diffHours = Math.floor(diffMins / 60);
        const diffDays = Math.floor(diffHours / 24);

        if (diffSecs < 60) return 'just now';
        if (diffMins < 60) return `${diffMins}m ago`;
        if (diffHours < 24) return `${diffHours}h ago`;
        if (diffDays < 7) return `${diffDays}d ago`;
        return past.toLocaleDateString();
    }

    _getCurrentTimeInfo() {
        const now = new Date();
        return {
            iso: now.toISOString().replace('T', ' ').substring(0, 19),
            timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
            human: now.toLocaleString('en-US', {
                weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
                hour: 'numeric', minute: '2-digit', hour12: true,
            }),
        };
    }
}

// Singleton
let _instance = null;

module.exports = {
    getInstance() {
        if (!_instance) _instance = new AiManager();
        return _instance;
    },
    AiManager,
};
