/**
 * BuddyController — Frontend-agnostic controller for the buddy companion.
 *
 * Owns: context buffer, idle timer, reaction throttle, name-call detection.
 * Extracted from InteractiveMode so both TUI and Telegram can compose it
 * without duplicating ~150 lines of buddy wiring logic.
 *
 * The host (TUI or Telegram) provides callbacks for frontend-specific rendering:
 * - onSpeech(text) — display a speech bubble / message
 * - onThinkingStart() / onThinkingEnd() — show/hide thinking indicator
 *
 * Policies are configurable via BuddyControllerConfig so the TUI (no limits)
 * and Telegram (activity gating + reaction budget) can use different strategies.
 */
import { checkOllama } from "./buddy-manager.js";
export class BuddyController {
    contextBuffer = [];
    lastReactionTime = 0;
    idleTimer = null;
    lastActivityTime = 0;
    reactionTimestamps = []; // for budget tracking
    pendingUtteranceId = 0;
    /** When false, all active functionality is disabled: no reactions, name-calls,
     *  idle timer, or Ollama calls. Context capture (passive) still happens. */
    enabled = true;
    manager;
    callbacks;
    config;
    constructor(manager, callbacks, config) {
        this.manager = manager;
        this.callbacks = callbacks;
        this.config = {
            contextMaxEntries: config?.contextMaxEntries ?? 20,
            idleTimeoutMs: config?.idleTimeoutMs ?? 30_000,
            reactionCooldownMs: config?.reactionCooldownMs ?? 60_000,
            activityGateMs: config?.activityGateMs ?? 0, // 0 = no gating (TUI default)
            reactionsPerHour: config?.reactionsPerHour ?? 0, // 0 = unlimited (TUI default)
        };
    }
    removeContextEntry(entry) {
        const idx = this.contextBuffer.indexOf(entry);
        if (idx !== -1) {
            this.contextBuffer.splice(idx, 1);
        }
    }
    replaceContextEntry(oldEntry, newEntry) {
        const idx = this.contextBuffer.indexOf(oldEntry);
        if (idx !== -1) {
            this.contextBuffer[idx] = newEntry.slice(0, 2000);
        }
        else {
            // Evicted — append normally
            this.appendContext(newEntry);
        }
    }
    // =========================================================================
    // Context buffer
    // =========================================================================
    /** Append an entry to the buddy context buffer (evicts oldest if at capacity) */
    appendContext(entry) {
        this.contextBuffer.push(entry.slice(0, 2000));
        if (this.contextBuffer.length > this.config.contextMaxEntries) {
            this.contextBuffer.shift();
        }
    }
    /** Build the context buffer into a string for LLM prompts */
    buildContext() {
        if (this.contextBuffer.length === 0) {
            return "No recent activity.";
        }
        return this.contextBuffer.join("\n").slice(0, 8000);
    }
    // =========================================================================
    // Activity & idle timer
    // =========================================================================
    /** Mark that user activity occurred (for activity gating) */
    markActivity() {
        this.lastActivityTime = Date.now();
    }
    /** Reset the idle timer — called on every user message */
    resetIdleTimer() {
        if (this.idleTimer) {
            clearTimeout(this.idleTimer);
        }
        if (!this.enabled)
            return;
        if (!this.manager.getState())
            return; // No buddy loaded, skip idle timer
        // Activity gating: skip idle timer if user has been inactive too long
        if (this.config.activityGateMs > 0 && this.lastActivityTime > 0) {
            const elapsed = Date.now() - this.lastActivityTime;
            if (elapsed > this.config.activityGateMs) {
                this.idleTimer = null;
                return;
            }
        }
        this.idleTimer = setTimeout(() => {
            const ctx = this.buildContext();
            this.triggerReaction(`It's been quiet for a moment. Recent activity:\n${ctx}`).catch(() => {
                /* triggerReaction() logs errors internally — prevents unhandled rejection */
            });
        }, this.config.idleTimeoutMs);
    }
    // =========================================================================
    // Reactions
    // =========================================================================
    /** Check if a reaction is allowed under current throttle and budget */
    canReact() {
        if (!this.enabled)
            return false;
        const now = Date.now();
        // Cooldown throttle
        if (now - this.lastReactionTime < this.config.reactionCooldownMs) {
            return false;
        }
        // Reaction budget (per hour)
        if (this.config.reactionsPerHour > 0) {
            const oneHourAgo = now - 3_600_000;
            this.reactionTimestamps = this.reactionTimestamps.filter((t) => t > oneHourAgo);
            if (this.reactionTimestamps.length >= this.config.reactionsPerHour) {
                return false;
            }
        }
        return true;
    }
    /**
     * Trigger a buddy reaction. Throttled by cooldown and budget.
     * Calls onThinkingStart/End and onSpeech callbacks.
     * No-op if disabled.
     */
    async triggerReaction(event) {
        if (!this.canReact())
            return;
        this.lastReactionTime = Date.now();
        const id = ++this.pendingUtteranceId;
        const marker = `__BUDDY_PENDING_${id}__`;
        this.appendContext(marker);
        let thinkingEnded = false;
        try {
            this.callbacks.onThinkingStart();
            const quip = await this.manager.react(event);
            thinkingEnded = true;
            this.callbacks.onThinkingEnd();
            if (quip) {
                this.reactionTimestamps.push(Date.now());
                this.replaceContextEntry(marker, `Buddy: ${quip}`);
                this.callbacks.onSpeech(quip);
            }
            else {
                this.removeContextEntry(marker);
            }
        }
        catch (err) {
            if (!thinkingEnded)
                this.callbacks.onThinkingEnd();
            this.removeContextEntry(marker);
            console.error("[buddy] triggerReaction failed:", err instanceof Error ? err.message : err);
        }
    }
    /**
     * Handle a name-call from the user.
     * No-op if disabled — returns immediately without calling Ollama.
     */
    async handleNameCall(userMessage) {
        if (!this.enabled)
            return;
        let state = this.manager.getState();
        if (!state) {
            state = this.manager.load();
        }
        if (!state)
            return;
        const id = ++this.pendingUtteranceId;
        const marker = `__BUDDY_PENDING_${id}__`;
        this.appendContext(marker);
        let thinkingEnded = false;
        try {
            this.callbacks.onThinkingStart();
            const response = await this.manager.respondToNameCall(userMessage, this.buildContext());
            thinkingEnded = true;
            this.callbacks.onThinkingEnd();
            if (response) {
                this.replaceContextEntry(marker, `Buddy: ${response}`);
                this.callbacks.onSpeech(response);
            }
            else {
                this.removeContextEntry(marker);
            }
        }
        catch (err) {
            if (!thinkingEnded)
                this.callbacks.onThinkingEnd();
            this.removeContextEntry(marker);
            console.error("[buddy] handleNameCall failed:", err instanceof Error ? err.message : err);
        }
    }
    /** Check if a message contains the buddy's name (word-boundary matching).
     *  Returns false if disabled. */
    detectNameCall(text) {
        if (!this.enabled)
            return false;
        const name = this.manager.getName();
        if (!name)
            return false;
        try {
            const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
            const regex = new RegExp(`\\b${escaped}\\b`, "i");
            return regex.test(text);
        }
        catch {
            /* Invalid regex from buddy name — safe to return false */
            return false;
        }
    }
    // =========================================================================
    // Event handling
    // =========================================================================
    /**
     * Process an agent event for buddy context capture and reaction triggers.
     * The host calls this from its event handler.
     *
     * Context capture always happens. Reactions are gated by `enabled`.
     */
    handleEvent(event) {
        const state = this.manager.getState();
        if (!state)
            return; // No buddy loaded
        switch (event.type) {
            case "message_end": {
                if (event.message?.role === "assistant") {
                    const textParts = event.message.content
                        ?.filter((c) => c.type === "text")
                        ?.map((c) => c.text)
                        ?.join("");
                    if (textParts) {
                        this.appendContext(`Assistant: ${textParts}`);
                    }
                    const toolCalls = event.message.content?.filter((c) => c.type === "toolCall") ?? [];
                    if (toolCalls.length > 0) {
                        const tools = toolCalls.map((c) => c.name).join(", ");
                        this.appendContext(`Called tools: ${tools}`);
                    }
                }
                break;
            }
            case "tool_execution_end": {
                // Context capture (always)
                const status = event.isError ? "failed" : "completed";
                const output = event.result?.output || event.result?.content;
                const outputText = typeof output === "string"
                    ? output
                    : Array.isArray(output)
                        ? output
                            .filter((c) => c.type === "text")
                            .map((c) => c.text)
                            .join("")
                        : "";
                this.appendContext(`Tool ${event.toolName} ${status}${outputText ? `: ${outputText}` : ""}`);
                // Reaction on error (gated by enabled)
                if (event.isError && this.enabled) {
                    let errorText = "unknown error";
                    const result = event.result;
                    if (result?.content && Array.isArray(result.content)) {
                        errorText = result.content
                            .filter((c) => c.type === "text")
                            .map((c) => c.text)
                            .join("");
                    }
                    else if (typeof result?.error === "string") {
                        errorText = result.error;
                    }
                    if (!errorText)
                        errorText = "unknown error";
                    this.triggerReaction(`Tool "${event.toolName}" failed: ${errorText.slice(0, 2000)}`).catch(() => {
                        /* triggerReaction() logs errors internally */
                    });
                }
                break;
            }
            case "agent_end": {
                if (this.enabled) {
                    const ctx = this.buildContext();
                    this.triggerReaction(`The agent finished responding. Recent activity:\n${ctx}`).catch(() => {
                        /* triggerReaction() logs errors internally */
                    });
                }
                break;
            }
        }
    }
    /**
     * Process a user message — captures context, resets idle, checks name-call.
     * Context capture always happens. Active features gated by `enabled`.
     * Returns true if a name-call was detected and is being handled.
     */
    processUserMessage(text) {
        this.appendContext(`User: ${text}`);
        this.markActivity();
        this.resetIdleTimer();
        // Name-call detection (gated by enabled via detectNameCall)
        if (this.detectNameCall(text)) {
            this.handleNameCall(text).catch(() => {
                /* handleNameCall() logs errors internally — prevents unhandled rejection */
            });
            return true;
        }
        return false;
    }
    // =========================================================================
    // Command handling
    // =========================================================================
    /**
     * Handle a /buddy command. Returns a result object for the frontend to render.
     * Hatch/reroll are delegated to the frontend via onHatch/onReroll callbacks.
     */
    async handleCommand(subcommand) {
        switch (subcommand) {
            case "pet": {
                if (!this.manager.getState()) {
                    return { type: "warning", message: "No buddy to pet! Use /buddy to hatch one first." };
                }
                return { type: "pet" };
            }
            case "reroll": {
                if (!this.manager.hasStoredBuddy()) {
                    return { type: "warning", message: "No buddy to reroll! Use /buddy to hatch one first." };
                }
                this.callbacks.onThinkingStart();
                try {
                    const state = await this.callbacks.onReroll(this.manager);
                    this.callbacks.onThinkingEnd();
                    this.enabled = true;
                    this.manager.setHidden(false);
                    return { type: "reroll", state };
                }
                catch (err) {
                    this.callbacks.onThinkingEnd();
                    return { type: "error", message: `Reroll failed: ${err instanceof Error ? err.message : String(err)}` };
                }
            }
            case "stats": {
                const state = this.manager.getState();
                if (!state) {
                    return { type: "warning", message: "No buddy to show stats for! Use /buddy to hatch one first." };
                }
                return { type: "stats", state };
            }
            case "off": {
                this.enabled = false;
                this.manager.setHidden(true);
                this.stop();
                return { type: "off" };
            }
            default: {
                // Handle "/buddy model" and "/buddy model <name>"
                if (subcommand === "model" || subcommand.startsWith("model ")) {
                    return this.handleModelCommand(subcommand);
                }
                // No subcommand: hatch or show
                const current = this.manager.getState();
                if (current) {
                    // Already showing — just enable and return
                    this.enabled = true;
                    this.manager.setHidden(false);
                    return { type: "show", state: current };
                }
                // Try to load existing buddy
                const existing = this.manager.load();
                if (existing) {
                    this.enabled = true;
                    this.manager.setHidden(false);
                    return { type: "show", state: existing };
                }
                // Hatch new buddy
                this.callbacks.onThinkingStart();
                try {
                    const hatchState = await this.callbacks.onHatch(this.manager);
                    this.callbacks.onThinkingEnd();
                    this.enabled = true;
                    return { type: "hatch", state: hatchState };
                }
                catch (err) {
                    this.callbacks.onThinkingEnd();
                    return { type: "error", message: `Hatch failed: ${err instanceof Error ? err.message : String(err)}` };
                }
            }
        }
    }
    // =========================================================================
    // Model selection
    // =========================================================================
    /** Handle /buddy model [name] — show current model or set a new one */
    async handleModelCommand(subcommand) {
        const modelArg = subcommand.slice("model".length).trim();
        if (!modelArg) {
            // "/buddy model" with no argument — show current + available
            const current = this.manager.getOllamaModel();
            const status = await checkOllama();
            if (!status.available) {
                return { type: "model", message: status.error ?? "Ollama is not available." };
            }
            const available = status.models.map((m) => `  • ${m}`).join("\n");
            if (current) {
                return {
                    type: "model",
                    message: `Current model: ${current}\n\nAvailable models:\n${available}\n\nChange with: /buddy model <name>`,
                };
            }
            return {
                type: "model",
                message: `No model set. Choose one with: /buddy model <name>\n\nAvailable models:\n${available}`,
            };
        }
        // "/buddy model <name>" — set the model
        if (!this.manager.getState() && !this.manager.hasStoredBuddy()) {
            return { type: "warning", message: "No buddy yet — hatch one first with /buddy, then set a model." };
        }
        const status = await checkOllama();
        if (!status.available) {
            return { type: "error", message: status.error ?? "Ollama is not available." };
        }
        // Check if the model is installed
        const match = status.models.find((m) => m === modelArg || m.startsWith(`${modelArg}:`));
        if (!match) {
            const available = status.models.map((m) => `  • ${m}`).join("\n");
            return {
                type: "error",
                message: `Model "${modelArg}" not found. Available models:\n${available}\n\nPull it first with: ollama pull ${modelArg}`,
            };
        }
        this.manager.setOllamaModel(match);
        return { type: "model", message: `Buddy model set to: ${match}` };
    }
    /** Check if an Ollama model is configured, return a nudge message if not */
    getModelNudge() {
        if (this.manager.getOllamaModel())
            return null;
        return "No Ollama model set — reactions are disabled. Run /buddy model to choose one.";
    }
    // =========================================================================
    // Lifecycle
    // =========================================================================
    /** Start the controller — auto-load buddy if one exists */
    start() {
        const existing = this.manager.load();
        if (existing) {
            // If buddy was hidden (via /buddy off), keep it loaded but disabled
            this.enabled = !existing.hidden;
            return existing;
        }
        return null;
    }
    /** Stop the controller — clear timers */
    stop() {
        if (this.idleTimer) {
            clearTimeout(this.idleTimer);
            this.idleTimer = null;
        }
    }
    /** Full reset — clear context buffer, idle timer, reaction budget.
     *  Respects persisted hidden state so bridge reconnects don't undo /buddy off. */
    reset() {
        this.stop();
        this.contextBuffer = [];
        this.lastReactionTime = 0;
        this.reactionTimestamps = [];
        // Re-enable unless buddy was explicitly hidden via /buddy off
        const state = this.manager.getState() ?? this.manager.load();
        this.enabled = state ? !state.hidden : true;
    }
}
//# sourceMappingURL=buddy-controller.js.map