package agent

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/ClareAI/astra-insight-mcp/internal/gateway"
	"github.com/ClareAI/astra-insight-mcp/internal/llm"
	"github.com/ClareAI/astra-insight-mcp/internal/mcp"
)

const maxIterations = 30

const (
	maxToolResultFresh = 6000 // raw cap before sending to summarizer
	summarizeThreshold = 500  // results smaller than this go straight to Opus
)

// Context window management — inspired by OpenClaw's two-phase pruning + compaction.
const (
	defaultContextWindowTokens = 200000 // Claude default; overridable via Options
	charsPerToken              = 4      // rough heuristic: 1 token ~ 4 chars

	pruneRatio    = 0.50 // 50% — trigger soft-trim of old tool results
	compactRatio  = 0.75 // 75% — trigger full conversation compaction
	hardLimitRatio = 0.90 // 90% — emergency: drop oldest turns

	softTrimMaxChars  = 4000 // tool results larger than this get soft-trimmed
	softTrimHeadChars = 1500
	softTrimTailChars = 1500

	keepLastAssistantTurns = 3 // protected zone: last N assistant turns are never pruned
)

const systemPromptBase = `You are an Astra Agent Optimizer — an expert AI assistant that helps users analyze, improve, and test their Astra AI agents.

## Tool Management
A small set of core tools is always available. Everything else is in **skills** — call activate_skill("name") to load a group when needed.

**Core tools (always loaded):**
- list_agents, get_agent, create_agent, update_agent, update_agent_instruction, publish_agent
- list_knowledge_documents, list_annotations, create_annotation, retrieve_knowledge
- send_test_message

**Skills (activate on demand via activate_skill — common skills are auto-activated based on your message):**
- branding — fetch_brandkit, create/update/delete brand kits
- conversations — list/get conversations, messages, contacts & leads, **get_conversations_batch** (fetch multiple at once)
- agent_setup — runtime config, clone/delete agent, actions, connections
- knowledge_management — create KB, URL/website import, annotation CRUD, document management
- integrations — Composio MCP: browse platforms (Slack, HubSpot, etc.), create/manage integrations, register tools with agents
- analytics — metrics & lead distribution
- testing — test pipeline + instruction optimization guide
- templates — browse pre-built agent templates
- webhooks — webhook CRUD
- voice_agent — voice agent management: quick-create from templates (only creation method), update/delete/publish, check existence by text agent
- voice_calls — voice call control: initiate/terminate WhatsApp/web/test/outbound calls, WebRTC config, ASR speech-to-text
- voice_clone — Minimax voice clone: upload_user_voice (audio → GCS), submit clones, list/manage voices, search voice library
- agent_creation_guide — expert workflow for creating agents (no tools, just knowledge)
- debugging_guide — troubleshooting reference for common API errors (no tools, just knowledge)

%s

## Efficiency Rules (CRITICAL — follow strictly)
- **Follow learned recipes.** If recipes from previous runs are provided as system context, follow their recommended tool sequences exactly. These are proven optimal paths.
- **Never re-fetch data you already have.** If you already called list_conversations or get_analytics, use those results. Do NOT call the same tool again with the same parameters.
- **Use batch tools.** When you need details for multiple conversations, use get_conversations_batch(conversation_ids=[...], include_messages=true) in a SINGLE call instead of calling get_conversation + get_conversation_messages repeatedly.
- **Maximize parallel tool calls.** When you need multiple independent pieces of data (e.g. analytics for different agents, or conversations + contacts), request them ALL in a single response so they execute in parallel.
- **Plan before acting.** Before making tool calls, think about what data you actually need and fetch it in the minimum number of iterations.
- **Handle errors gracefully.** If messages return 404 or empty, those messages have expired — do NOT retry. Work with the data you have (conversation metadata, summaries, etc.).

## Optimization Workflow
When the user reports an issue, requests improvement, or asks you to optimize an agent:
**Steps 1–3 are fully automatic — execute them back-to-back without pausing for user input.**
**Step 4 is a HARD STOP — you MUST wait for the user to say "yes" before publishing.**

### Step 1 — Analyze (auto)
Gather all data IN PARALLEL in a single iteration:
- get_agent(agent_id) + list_conversations + get_analytics + get_conversations_batch
- Identify failure patterns, quote specific conversation examples
- Produce a brief analysis (2-3 bullet points)
Do NOT stop here to ask the user. Immediately proceed to Step 2.

### Step 2 — Draft Instruction (auto)
Write an improved instruction based on Step 1 findings:
- Preserve what works, fix what's broken
- Structured markdown: Role & Identity, Core Flow, Guardrails, Communication Style
- Under 4000 tokens; embed guardrails directly
Save via update_agent_instruction(agent_id, instructions=<new_draft>).
Do NOT stop here to show a diff or ask for approval. Immediately proceed to Step 3.

### Step 3 — Test (auto)
activate_skill("testing") and validate the draft:
- start_test_pipeline(agent_id, user_id="insight-mcp-tester", instruction=<new_draft>)
- Poll get_pipeline_job until complete
- Fallback: publish_agent → send_test_message with 2-3 key scenarios → report results
If regressions found, revise and re-test (max 2 retries).
Do NOT stop here. Immediately proceed to Step 4.

### Step 4 — Report & Confirm (HARD STOP)
Present a single consolidated report to the user:
1. **Issues found** — bullet points from Step 1
2. **Changes made** — key diffs from Step 2
3. **Test results** — pass rate, improvements, any remaining concerns from Step 3
4. **Ask explicitly**: "Should I publish these changes to make them live?"

⚠️ NEVER call publish_agent() until the user replies with explicit confirmation (e.g. "yes", "go ahead", "publish it").
If the user says no or asks for changes, go back to Step 2 and repeat.

## General Workflow (non-optimization tasks)
- Integrate: activate_skill("integrations") → get_integration_platforms → create_integration → register_agent_integrations
- Brand: activate_skill("branding") → fetch_brandkit → create_brandkit
- Quick test: send_test_message (requires published agent)
- Publish: publish_agent

## Voice Agent Workflow
When the user asks to create a voice agent, follow this strict sequence:
1. Create text agent first: create_agent (if one doesn't already exist)
2. Activate voice skill: activate_skill("voice_agent")
3. Check existence: get_voice_agent_by_text_agent(text_agent_id) — avoid duplicates
4. Create voice agent: quick_create_voice_agent(text_agent_id, template_id) — this is the ONLY way to create a voice agent
5. Configure: update_voice_agent(voice_agent_id, instruction=..., agent_config={...})
6. Publish: publish_voice_agent(voice_agent_id)
IMPORTANT: Voice agents MUST always be linked to a text agent. Never skip step 1 or 3. Always use quick_create_voice_agent — there is no other creation method.

Language rules for voice agents:
- agent_config.language must be a valid code: en, zh, yue, es, fr, de, ja, ko, pt, it, ru, ar, hi, th, vi, id, ms, nl, pl, tr, sv, no, da, fi, el, he, uk, cs, ro, hu, bg, fa, sk, hr, tl, sl, ca, nn, ta, af. Default: en.
- NEVER set language to "multilingual". For multi-language support, set language to the primary language (default en) and set prompt_config.auto_language_switching=true.

## Webhook Context
Messages from the webhook include context in this format at the top:
  [Webhook context | wa_id: <phone> | sender_name: <name> | conversation_id: <id>]

- wa_id is the user's WhatsApp ID (phone number). Use it directly for initiate_outbound_call(waid=<wa_id>) — NEVER ask the user for their phone number.
- sender_name is the user's display name. Use it for personalization.
- tenant_id for outbound calls is auto-resolved from WATI_TENANT_ID — you do not need to provide it.

## Voice Message Handling
Messages from voice input arrive in this format:
  [Voice message | audio_url: <url> | format: <filename>]
  <transcribed text>

The audio_url is the original audio file sent by the user. **It requires authentication to download — never pass it directly to external APIs.**

When the user's intent involves using their audio (e.g., "use my voice for the agent", "clone my voice"):
1. activate_skill("voice_clone")
2. upload_user_voice(audio_url=<url>) — tell user "Audio uploaded successfully", do NOT mention storage details
3. submit_voice_clone(tenant_id, voice_name, gcs_url=<returned gcs_url>) — tell user "Voice cloning started"
4. Poll get_minimax_voice(id) until status=completed (auto-confirms tentative). Do NOT tell user about internal statuses.
5. When completed, share the demo_audio URL so user can listen.
6. CRITICAL — MUST NOT SKIP: Take agent_config_voice_id from get_minimax_voice response and call:
   update_voice_agent(voice_agent_id, agent_config={"voice_id": "<agent_config_voice_id>"})
   Then call publish_voice_agent(voice_agent_id).
   Without this step the cloned voice will NOT take effect on the agent.

User-facing messages should only say: uploading → cloning in progress → done, here is a preview → voice applied to your agent.
Never expose to user: GCS URLs, internal status names, agent_config_voice_id, clone_model, minimax details.
If the user is simply giving a verbal command, just process the transcribed text normally.

## Current Time
Today is %s (UTC). Use this to resolve relative time expressions like "last 7 days", "this month", "yesterday", etc. into absolute date ranges for API calls (e.g. start_time, end_time).

Always explain your reasoning. When you find issues in conversations, quote specific examples.
Respond in the user's language (Chinese if they write in Chinese).`

// buildSystemPrompt constructs the system prompt. When hasOverride is true
// (e.g. per-user OAuth token via webhook), the prompt always says "authenticated".
func buildSystemPrompt(hasOverride bool) string {
	authSection := `## Authentication
Authentication is handled automatically at the transport layer — all API calls are pre-authenticated.
Do NOT ask the user for an API key or token. Do NOT pass api_key in tool calls.`

	if hasOverride {
		authSection += `
The user authenticated via OAuth. Available commands (handled automatically, not by you):
- Type **logout** or **/logout** to disconnect and clear credentials.
- After logout, sending any message triggers a new login link.
If the user asks how to log out, tell them to type "logout".`
	}

	if !hasOverride && !hasAuthToken() {
		authSection = `## Authentication
No Astra platform API key detected. Tell the user:
  • Restart astra and enter the key at the "Step 2/2" prompt, OR
  • Set ASTRA_API_KEY in environment, OR
  • Run: astra auth --oauth
Do NOT pass api_key in tool calls — authentication is handled at the transport layer.`
	}

	now := time.Now().UTC().Format("2006-01-02 (Monday)")
	return fmt.Sprintf(systemPromptBase, authSection, now)
}

func hasAuthToken() bool {
	for _, key := range []string{"ASTRA_API_KEY", "ASTRA_ACCESS_TOKEN", "ASTRA_OAUTH_ACCESS_TOKEN"} {
		if os.Getenv(key) != "" {
			return true
		}
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return false
	}
	_, err = os.Stat(filepath.Join(home, ".astra", "oauth_token.json"))
	return err == nil
}

// SkillPruner can deactivate skills whose tools were not used in a conversation turn.
type SkillPruner interface {
	DeactivateUnused(usedTools map[string]bool) []string
}

// SkillActivator can pre-activate skills before the LLM loop starts.
type SkillActivator interface {
	ActivateIfInactive(name string)
}

// Options configures optional Agent behavior.
type Options struct {
	FastClient          *llm.Client  // cheap model used ONLY for summarizing tool results
	APIKeyOverride      string       // if set, injected as api_key into every tool call (per-user OAuth)
	ContextWindowTokens int          // model context window size; 0 = defaultContextWindowTokens
	SkillPruner         SkillPruner  // auto-deactivates unused skills after each Run
	RecipeStore         RecipeStore  // persists learned tool-sequence recipes across runs
}

// Agent orchestrates the LLM + tool loop.
type Agent struct {
	llmClient          *llm.Client // primary model — all decisions, all user-facing output
	fastClient         *llm.Client // cheap model — only summarizes tool results, never decides
	gwClient           *gateway.Client
	srv                *mcp.Server
	tools              []llm.Tool
	handlers           map[string]mcp.ToolHandler
	messages           []llm.Message
	apiKeyOverride      string // per-user token injected into every tool call
	contextWindowTokens int    // max tokens the model can handle
	skillPruner         SkillPruner
	recipeStore         RecipeStore
	usedTools           map[string]bool // tools called during current Run; reset per Run
	toolTrace           []ToolTraceEntry // tool call trace for post-run learning; reset per Run
	iterationCount      int              // number of iterations in current Run
	checkpoint          int             // message index at last MarkCheckpoint; used for incremental export
	onContent           func(string)
	onToolCall         func(name string, args map[string]interface{})
	onToolDone         func(name string, result string)
	onUsage            func(usage llm.Usage)
	onIterationContent func(content string)
}

type Callbacks struct {
	OnContent          func(string)
	OnToolCall         func(name string, args map[string]interface{})
	OnToolDone         func(name string, result string)
	OnUsage            func(usage llm.Usage)
	OnIterationContent func(content string) // called when an iteration has content AND pending tool calls
}

func New(llmClient *llm.Client, gwClient *gateway.Client, cb Callbacks, opts ...Options) *Agent {
	a := &Agent{
		llmClient:          llmClient,
		gwClient:           gwClient,
		handlers:           make(map[string]mcp.ToolHandler),
		onContent:          cb.OnContent,
		onToolCall:         cb.OnToolCall,
		onToolDone:         cb.OnToolDone,
		onUsage:            cb.OnUsage,
		onIterationContent: cb.OnIterationContent,
	}

	if len(opts) > 0 {
		a.fastClient = opts[0].FastClient
		a.apiKeyOverride = opts[0].APIKeyOverride
		a.contextWindowTokens = opts[0].ContextWindowTokens
		a.skillPruner = opts[0].SkillPruner
		a.recipeStore = opts[0].RecipeStore
	}
	if a.contextWindowTokens <= 0 {
		a.contextWindowTokens = defaultContextWindowTokens
	}

	a.messages = []llm.Message{
		{Role: "system", Content: buildSystemPrompt(a.apiKeyOverride != "")},
	}

	a.registerTools()
	return a
}

func (a *Agent) registerTools() {
	a.srv = mcp.NewServer("cli", "0.0.1")
	if pruner := registerAllToolsOnServer(a.srv, a.gwClient); pruner != nil && a.skillPruner == nil {
		a.skillPruner = pruner
	}
	a.tools, a.handlers = extractFromServer(a.srv)
}

// refreshTools re-extracts tool definitions from the MCP server,
// picking up any tools added/removed by skill activation.
func (a *Agent) refreshTools() {
	if a.srv == nil {
		return
	}
	a.tools, a.handlers = extractFromServer(a.srv)
}

// ---- Context window management ----

// ManageContextNow triggers context management (pruning + compaction) immediately.
// Exported for use by the webhook cleanup loop for idle compaction.
func (a *Agent) ManageContextNow(ctx context.Context) {
	a.manageContext(ctx)
}

// estimateTokens returns a rough token count for messages + tool definitions.
func (a *Agent) estimateTokens() int {
	total := 0
	for _, m := range a.messages {
		total += len(m.Content)
		for _, tc := range m.ToolCalls {
			total += len(tc.Function.Name) + len(tc.Function.Arguments)
		}
	}
	total += a.estimateToolDefTokens()
	return total / charsPerToken
}

// estimateToolDefTokens estimates the character cost of all tool definitions
// sent with each request (names, descriptions, parameter schemas).
func (a *Agent) estimateToolDefTokens() int {
	total := 0
	for _, t := range a.tools {
		total += len(t.Function.Name) + len(t.Function.Description)
		if t.Function.Parameters != nil {
			b, _ := json.Marshal(t.Function.Parameters)
			total += len(b)
		}
	}
	return total
}

// contextUsageRatio returns 0.0–1.0+ indicating how full the context window is.
func (a *Agent) contextUsageRatio() float64 {
	return float64(a.estimateTokens()) / float64(a.contextWindowTokens)
}

// manageContext runs the layered context management strategy before each LLM call:
//  1. Always soft-trim old tool results (cheap, in-place)
//  2. If > compactRatio, summarize old conversation turns via fast model
//  3. If > hardLimitRatio, emergency drop oldest turns
func (a *Agent) manageContext(ctx context.Context) {
	a.pruneToolResults()

	ratio := a.contextUsageRatio()
	slog.Debug("agent: context status",
		"estimated_tokens", a.estimateTokens(),
		"window", a.contextWindowTokens,
		"usage_ratio", fmt.Sprintf("%.1f%%", ratio*100),
		"messages", len(a.messages),
	)

	if ratio > compactRatio {
		a.compactConversation(ctx)
		ratio = a.contextUsageRatio()
	}

	if ratio > hardLimitRatio {
		a.emergencyTruncate()
	}
}

// pruneToolResults implements two-phase pruning of old tool results.
// Phase 1 (soft-trim): keep head + tail of oversized results.
// Phase 2 (hard-clear): replace oldest tool results with a placeholder.
func (a *Agent) pruneToolResults() {
	protectedIdx := a.findProtectedCutoff()

	// Phase 1: soft-trim oversized tool results before the protected zone
	for i := 0; i < protectedIdx; i++ {
		m := &a.messages[i]
		if m.Role != "tool" || len(m.Content) <= softTrimMaxChars {
			continue
		}
		if strings.HasPrefix(m.Content, "Error:") {
			continue
		}
		a.messages[i].Content = softTrimContent(m.Name, m.Content)
	}

	// Phase 2: if still over pruneRatio, hard-clear oldest tool results
	if a.contextUsageRatio() > pruneRatio {
		for i := 0; i < protectedIdx; i++ {
			m := &a.messages[i]
			if m.Role != "tool" || strings.HasPrefix(m.Content, "[") {
				continue
			}
			origLen := len(m.Content)
			a.messages[i].Content = fmt.Sprintf("[%s: result cleared — %d chars]", m.Name, origLen)

			if a.contextUsageRatio() <= pruneRatio {
				break
			}
		}
	}
}

// findProtectedCutoff returns the message index before which pruning is allowed.
// Protects the last keepLastAssistantTurns assistant messages and everything after.
func (a *Agent) findProtectedCutoff() int {
	count := 0
	for i := len(a.messages) - 1; i >= 0; i-- {
		if a.messages[i].Role == "assistant" {
			count++
			if count >= keepLastAssistantTurns {
				return i
			}
		}
	}
	return 0
}

// softTrimContent keeps the head and tail of a tool result, trimming the middle.
func softTrimContent(name, content string) string {
	trimmed := len(content) - softTrimHeadChars - softTrimTailChars
	head := content[:softTrimHeadChars]
	tail := content[len(content)-softTrimTailChars:]
	return fmt.Sprintf("%s\n...[%s: trimmed %d chars]...\n%s", head, name, trimmed, tail)
}

// compactConversation summarizes old conversation turns via the fast model,
// replacing them with a single compact summary to free context space.
func (a *Agent) compactConversation(ctx context.Context) {
	if a.fastClient == nil {
		slog.Warn("agent: compaction skipped — no fast model available")
		return
	}

	protectedIdx := a.findProtectedCutoff()
	if protectedIdx <= 1 {
		return // nothing to compact (only system prompt + recent turns)
	}

	// Find where non-system messages start (skip system prompt + any existing summaries)
	contentStart := 0
	for i := 0; i < protectedIdx; i++ {
		if a.messages[i].Role == "system" {
			contentStart = i + 1
		} else {
			break
		}
	}
	if contentStart >= protectedIdx {
		return
	}

	// Collect old messages for summarization
	var oldMsgs []string
	for i := contentStart; i < protectedIdx; i++ {
		m := a.messages[i]
		switch m.Role {
		case "system":
			oldMsgs = append(oldMsgs, fmt.Sprintf("Context: %s", truncate(m.Content, 2000)))
		case "user":
			oldMsgs = append(oldMsgs, fmt.Sprintf("User: %s", truncate(m.Content, 2000)))
		case "assistant":
			content := truncate(m.Content, 2000)
			if len(m.ToolCalls) > 0 {
				var calls []string
				for _, tc := range m.ToolCalls {
					calls = append(calls, tc.Function.Name)
				}
				content += fmt.Sprintf(" [called: %s]", strings.Join(calls, ", "))
			}
			oldMsgs = append(oldMsgs, fmt.Sprintf("Assistant: %s", content))
		case "tool":
			oldMsgs = append(oldMsgs, fmt.Sprintf("Tool(%s): %s", m.Name, truncate(m.Content, 500)))
		}
	}

	if len(oldMsgs) == 0 {
		return
	}

	slog.Debug("agent: compacting conversation",
		"old_messages", protectedIdx-1,
		"pre_compact_tokens", a.estimateTokens(),
	)

	prompt := []llm.Message{
		{
			Role: "system",
			Content: `You are a conversation compactor. Summarize the following conversation history into a concise summary.
Rules:
- Preserve ALL UUIDs, IDs, agent names, voice IDs, and resource identifiers EXACTLY as they appear
- Preserve decisions made, actions completed (with their results), and any pending/incomplete work
- Preserve the user's current goals, preferences, and any configuration choices
- Note any errors encountered and how they were resolved
- Include key tool outputs: created resource IDs, status changes, configuration values
- Be concise but thorough — the agent loses access to the original messages after this
- Output plain text, no markdown`,
		},
		{Role: "user", Content: strings.Join(oldMsgs, "\n\n")},
	}

	msg, usage, err := a.fastClient.ChatStream(ctx, prompt, nil, nil)
	if err != nil {
		slog.Error("agent: compaction failed", "error", err)
		return
	}
	if usage != nil && a.onUsage != nil {
		a.onUsage(*usage)
	}

	// Replace old messages with the compacted summary
	summary := llm.Message{
		Role:    "system",
		Content: fmt.Sprintf("[Compacted conversation summary]\n%s", msg.Content),
	}

	newMessages := make([]llm.Message, 0, 2+len(a.messages)-protectedIdx)
	newMessages = append(newMessages, a.messages[0]) // system prompt
	newMessages = append(newMessages, summary)
	newMessages = append(newMessages, a.messages[protectedIdx:]...)
	a.messages = newMessages

	// Keep checkpoint valid: if compaction shrank messages below the checkpoint,
	// clamp it so ExportNewMessages still captures messages added during this Run.
	if a.checkpoint > len(a.messages) {
		a.checkpoint = len(a.messages)
	}

	slog.Debug("agent: compaction done",
		"summary_len", len(msg.Content),
		"post_compact_tokens", a.estimateTokens(),
		"messages_after", len(a.messages),
	)
}

// sanitizeOrphanedToolResults strips tool-result messages from the front of
// a message slice when their corresponding assistant tool_use was truncated.
// This happens when the Redis sliding window or emergency truncation cuts in
// the middle of an assistant→tool-result sequence. Anthropic Claude rejects
// requests containing tool_results without matching tool_use blocks.
func sanitizeOrphanedToolResults(msgs []llm.Message) []llm.Message {
	dropped := 0
	for len(msgs) > 0 && msgs[0].Role == "tool" {
		msgs = msgs[1:]
		dropped++
	}
	if dropped > 0 {
		slog.Warn("agent: stripped orphaned tool results from message start", "dropped", dropped)
	}

	// Also verify interior integrity: if an assistant with tool_calls was
	// removed but its tool results remain, those results would reference
	// IDs the model never saw. Walk forward and drop any tool message whose
	// tool_call_id doesn't match the immediately preceding assistant's calls.
	cleaned := make([]llm.Message, 0, len(msgs))
	var lastAssistantToolIDs map[string]bool
	for _, m := range msgs {
		if m.Role == "assistant" {
			lastAssistantToolIDs = make(map[string]bool)
			for _, tc := range m.ToolCalls {
				lastAssistantToolIDs[tc.ID] = true
			}
			cleaned = append(cleaned, m)
		} else if m.Role == "tool" && m.ToolCallID != "" {
			if lastAssistantToolIDs[m.ToolCallID] {
				cleaned = append(cleaned, m)
			} else {
				slog.Warn("agent: dropped orphaned tool result", "tool_call_id", m.ToolCallID, "tool", m.Name)
			}
		} else {
			lastAssistantToolIDs = nil
			cleaned = append(cleaned, m)
		}
	}
	return cleaned
}

// emergencyTruncate drops the oldest non-system messages to get under the hard limit.
func (a *Agent) emergencyTruncate() {
	slog.Warn("agent: emergency truncation triggered",
		"tokens", a.estimateTokens(),
		"window", a.contextWindowTokens,
	)

	// Keep system prompt(s) at the front, then drop from the front until under limit
	systemEnd := 0
	for i, m := range a.messages {
		if m.Role == "system" {
			systemEnd = i + 1
		} else {
			break
		}
	}

	for a.contextUsageRatio() > hardLimitRatio && len(a.messages) > systemEnd+2 {
		a.messages = append(a.messages[:systemEnd], a.messages[systemEnd+1:]...)
	}

	// Truncation may have removed an assistant message while leaving its
	// tool results — sanitize to prevent Claude tool_use_id mismatch errors.
	if systemEnd < len(a.messages) {
		tail := sanitizeOrphanedToolResults(a.messages[systemEnd:])
		a.messages = append(a.messages[:systemEnd], tail...)
	}

	if a.checkpoint > len(a.messages) {
		a.checkpoint = len(a.messages)
	}

	slog.Debug("agent: emergency truncation done",
		"tokens_after", a.estimateTokens(),
		"messages_after", len(a.messages),
	)
}

// TranscriptEntry is a serializable record of one message in the conversation.
// ToolTraceEntry records a single tool invocation for post-run learning.
type ToolTraceEntry struct {
	Iteration int    `json:"iter"`
	Tool      string `json:"tool"`
	Error     bool   `json:"error,omitempty"`
	ResultLen int    `json:"result_len"`
}

// Recipe is a learned optimal tool sequence extracted after a run.
type Recipe struct {
	Task      string `json:"task"`       // task category, e.g. "analyze_conversations"
	Content   string `json:"content"`    // human-readable recipe text
	HitCount  int64  `json:"hit_count"`  // number of times this recipe was loaded into a session
	CreatedAt string `json:"created_at"` // RFC3339
	UpdatedAt string `json:"updated_at"` // RFC3339, last time the recipe content was refreshed
	LastHitAt string `json:"last_hit_at,omitempty"` // RFC3339, last time this recipe was loaded
}

type TranscriptEntry struct {
	Role      string   `json:"role"`
	Content   string   `json:"content,omitempty"`
	ToolCalls []string `json:"tool_calls,omitempty"` // tool function names
	ToolName  string   `json:"tool_name,omitempty"`  // for role=tool
	Timestamp string   `json:"ts"`
}

// ExportTranscript returns the full conversation (excluding the system prompt)
// as a serializable slice — the equivalent of OpenClaw's append-only .jsonl.
func (a *Agent) ExportTranscript() []TranscriptEntry {
	var entries []TranscriptEntry
	for _, m := range a.messages {
		if m.Role == "system" && !strings.HasPrefix(m.Content, "[Compacted conversation summary]") &&
			!strings.HasPrefix(m.Content, "[Restored") {
			continue // skip base system prompt
		}
		e := TranscriptEntry{
			Role:    m.Role,
			Content: m.Content,
		}
		for _, tc := range m.ToolCalls {
			e.ToolCalls = append(e.ToolCalls, tc.Function.Name)
		}
		if m.Name != "" {
			e.ToolName = m.Name
		}
		entries = append(entries, e)
	}
	return entries
}

// MarkCheckpoint records the current message count so that ExportSinceCheckpoint
// can later return only the messages added after this point.
// Call this before agent.Run() to capture the "before" state.
func (a *Agent) MarkCheckpoint() {
	a.checkpoint = len(a.messages)
}

// ExportSinceCheckpoint returns transcript entries added since the last MarkCheckpoint.
// This is the incremental delta suitable for RPUSH to Redis after each Run().
func (a *Agent) ExportSinceCheckpoint() []TranscriptEntry {
	if a.checkpoint >= len(a.messages) {
		return nil
	}
	var entries []TranscriptEntry
	for _, m := range a.messages[a.checkpoint:] {
		if m.Role == "system" {
			continue
		}
		e := TranscriptEntry{
			Role:    m.Role,
			Content: m.Content,
		}
		for _, tc := range m.ToolCalls {
			e.ToolCalls = append(e.ToolCalls, tc.Function.Name)
		}
		if m.Name != "" {
			e.ToolName = m.Name
		}
		entries = append(entries, e)
	}
	return entries
}

// ExtractMemory uses the fast model to produce a structured long-term memory
// blob from the current conversation — inspired by OpenClaw's pre-compaction
// memory flush. The result is a durable record of IDs, decisions, preferences
// and pending work that survives compaction and session expiry.
func (a *Agent) ExtractMemory(ctx context.Context) string {
	if a.fastClient == nil || len(a.messages) <= 1 {
		return ""
	}

	var conv []string
	for _, m := range a.messages {
		switch m.Role {
		case "system":
			if strings.HasPrefix(m.Content, "[Compacted conversation summary]") ||
				strings.HasPrefix(m.Content, "[Restored") {
				conv = append(conv, "Context: "+truncate(m.Content, 2000))
			}
		case "user":
			conv = append(conv, "User: "+truncate(m.Content, 1500))
		case "assistant":
			content := truncate(m.Content, 1500)
			if len(m.ToolCalls) > 0 {
				var names []string
				for _, tc := range m.ToolCalls {
					names = append(names, tc.Function.Name)
				}
				content += fmt.Sprintf(" [tools: %s]", strings.Join(names, ", "))
			}
			conv = append(conv, "Assistant: "+content)
		case "tool":
			conv = append(conv, fmt.Sprintf("Tool(%s): %s", m.Name, truncate(m.Content, 800)))
		}
	}

	if len(conv) == 0 {
		return ""
	}

	prompt := []llm.Message{
		{
			Role: "system",
			Content: `You are a memory extractor. Analyze the conversation and produce a structured long-term memory document.

Output format (plain text, keep concise):

## User Profile
- Name / wa_id / any identifiers mentioned
- Language preference, timezone, or other personal context

## Resources
- List ALL resource IDs created or referenced: agent IDs, voice agent IDs, voice IDs, knowledge doc IDs, etc.
- Format: resource_type: id (name if known)

## Decisions & Configuration
- What was configured, changed, or decided
- Key parameter values (language, voice, greeting, instruction summaries)

## Pending Work
- Anything discussed but not yet completed
- Follow-up items the user mentioned

## Key Facts
- Important business context, domain knowledge, or preferences
- Errors encountered and their resolutions

Rules:
- Preserve ALL UUIDs and IDs exactly as they appear
- Be concise — max 600 words
- Omit sections that have no content
- Output plain text only`,
		},
		{Role: "user", Content: strings.Join(conv, "\n\n")},
	}

	msg, _, err := a.fastClient.ChatStream(ctx, prompt, nil, nil)
	if err != nil {
		slog.Error("agent: memory extraction failed", "error", err)
		return ""
	}
	slog.Debug("agent: memory extracted", "memory_len", len(msg.Content))
	return msg.Content
}

// ExtractRecipe uses the primary model to produce a reusable "recipe" from the
// current run's tool trace. The recipe describes the optimal tool sequence for
// the task so future runs can skip detours.
// Returns nil if the run was too short or already efficient.
func (a *Agent) ExtractRecipe(ctx context.Context) *Recipe {
	if len(a.toolTrace) < 3 || a.iterationCount < 2 {
		return nil
	}

	// Collect all user messages to understand the full intent, not just the first one.
	var userMessages []string
	for _, m := range a.messages {
		if m.Role == "user" {
			userMessages = append(userMessages, truncate(m.Content, 300))
		}
	}

	// Also collect the final assistant reply to understand the outcome.
	var finalReply string
	for i := len(a.messages) - 1; i >= 0; i-- {
		if a.messages[i].Role == "assistant" && a.messages[i].Content != "" {
			finalReply = truncate(a.messages[i].Content, 500)
			break
		}
	}

	var traceLines []string
	curIter := 0
	for _, t := range a.toolTrace {
		if t.Iteration != curIter {
			curIter = t.Iteration
			traceLines = append(traceLines, fmt.Sprintf("\n--- Iteration %d ---", curIter))
		}
		status := "ok"
		if t.Error {
			status = "ERROR"
		}
		traceLines = append(traceLines, fmt.Sprintf("  %s → %s (len=%d)", t.Tool, status, t.ResultLen))
	}

	var inputBuilder strings.Builder
	fmt.Fprintf(&inputBuilder, "User messages:\n")
	for i, msg := range userMessages {
		fmt.Fprintf(&inputBuilder, "  [%d] %s\n", i+1, msg)
	}
	fmt.Fprintf(&inputBuilder, "\nFinal outcome: %s\n", finalReply)
	fmt.Fprintf(&inputBuilder, "\nIterations: %d | Tool calls: %d\n\nTool trace:\n%s",
		a.iterationCount, len(a.toolTrace), strings.Join(traceLines, "\n"))

	prompt := []llm.Message{
		{
			Role: "system",
			Content: `You are an efficiency analyst. Given a user's request, the agent's outcome, and the tool trace, produce a JSON object with two fields:

1. "task": a short snake_case label that describes THE USER'S INTENT (not the tools used). Derive it from what the user asked for, not from which tools were called.
   Examples:
   - User asks to improve/optimize an agent → "optimize_agent_instruction"
   - User asks about conversation quality → "analyze_conversation_quality"
   - User asks about lead distribution → "check_lead_distribution"
   - User asks to create an agent → "create_agent"
   - User asks to create a voice agent → "create_voice_agent"
   - User asks to set up integrations → "setup_integration"
   - User asks about analytics/metrics → "get_analytics_metrics"
   - User asks to manage knowledge base → "manage_knowledge_base"
   - User asks to test an agent → "test_agent"
   - User asks to debug errors → "debug_agent_errors"
   Be specific — "optimize_agent_instruction" is better than "analyze_conversations" when the user asked to optimize.

2. "content": a concise recipe (under 150 words) describing the OPTIMAL path:
   - List the minimum tools needed in the optimal order
   - Note which tools should be called in PARALLEL (same iteration)
   - Note which tools are UNNECESSARY for this task type (detours)
   - Note any errors and how to avoid them

Output ONLY valid JSON: {"task":"...","content":"..."}
If the run was already efficient (≤3 iterations, no errors), output exactly: {"task":"EFFICIENT","content":""}`,
		},
		{Role: "user", Content: inputBuilder.String()},
	}

	msg, _, err := a.llmClient.ChatStream(ctx, prompt, nil, nil)
	if err != nil {
		slog.Error("agent: recipe extraction failed", "error", err)
		return nil
	}

	raw := strings.TrimSpace(msg.Content)
	// Strip markdown code fences (```json ... ``` or ``` ... ```)
	if strings.HasPrefix(raw, "```") {
		if idx := strings.Index(raw[3:], "\n"); idx >= 0 {
			raw = raw[3+idx+1:]
		}
		if strings.HasSuffix(raw, "```") {
			raw = raw[:len(raw)-3]
		}
		raw = strings.TrimSpace(raw)
	}
	var parsed struct {
		Task    string `json:"task"`
		Content string `json:"content"`
	}
	if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
		slog.Warn("agent: recipe parse failed, falling back to raw", "error", err, "raw", truncate(raw, 200))
		parsed.Task = "unknown"
		parsed.Content = raw
	}

	if parsed.Task == "EFFICIENT" || parsed.Content == "" {
		slog.Debug("agent: run was efficient, no recipe needed", "iterations", a.iterationCount)
		return nil
	}

	now := time.Now().UTC().Format(time.RFC3339)
	recipe := &Recipe{
		Task:      parsed.Task,
		Content:   parsed.Content,
		CreatedAt: now,
		UpdatedAt: now,
	}

	slog.Debug("agent: recipe extracted", "task", recipe.Task, "content_len", len(recipe.Content), "iterations", a.iterationCount)
	return recipe
}

// InjectRestoredContext loads both long-term memory and recent transcript
// into the agent's message history for a new session.
// memory is the durable cross-session facts; recentTranscript is the tail
// of the last session's conversation (truncated to fit).
func (a *Agent) InjectRestoredContext(memory string, recentTranscript []TranscriptEntry) {
	var injected []llm.Message

	if memory != "" {
		injected = append(injected, llm.Message{
			Role:    "system",
			Content: fmt.Sprintf("[Long-term memory from previous sessions]\n%s", memory),
		})
	}

	if len(recentTranscript) > 0 {
		const maxReplayEntries = 20
		start := 0
		if len(recentTranscript) > maxReplayEntries {
			start = len(recentTranscript) - maxReplayEntries
		}

		var lines []string
		for _, e := range recentTranscript[start:] {
			line := fmt.Sprintf("%s: %s", e.Role, truncate(e.Content, 800))
			if len(e.ToolCalls) > 0 {
				line += fmt.Sprintf(" [tools: %s]", strings.Join(e.ToolCalls, ", "))
			}
			lines = append(lines, line)
		}

		injected = append(injected, llm.Message{
			Role:    "system",
			Content: fmt.Sprintf("[Recent conversation from previous session]\n%s", strings.Join(lines, "\n")),
		})
	}

	if len(injected) == 0 {
		return
	}

	// Insert after system prompt (index 0)
	tail := make([]llm.Message, len(a.messages)-1)
	copy(tail, a.messages[1:])
	a.messages = append(a.messages[:1], append(injected, tail...)...)

	slog.Debug("agent: restored context from previous session",
		"memory", memory != "",
		"transcript_entries", len(recentTranscript),
	)
}

// InjectRecipes adds learned recipes (optimal tool sequences from previous runs)
// as a system message so the agent can follow proven patterns.
func (a *Agent) InjectRecipes(recipes []string) {
	if len(recipes) == 0 {
		return
	}

	var b strings.Builder
	b.WriteString("[Learned recipes from previous runs — follow these optimal tool sequences]\n\n")
	for i, r := range recipes {
		fmt.Fprintf(&b, "### Recipe %d\n%s\n\n", i+1, r)
	}

	msg := llm.Message{
		Role:    "system",
		Content: b.String(),
	}

	// Insert after the base system prompt (index 0).
	tail := make([]llm.Message, len(a.messages)-1)
	copy(tail, a.messages[1:])
	a.messages = append(a.messages[:1], append([]llm.Message{msg}, tail...)...)

	slog.Debug("agent: injected recipes", "count", len(recipes))
}

// loadRecipes loads recipes from the store and injects them into the conversation.
func (a *Agent) loadRecipes(ctx context.Context) {
	if a.recipeStore == nil {
		return
	}
	recipes := a.recipeStore.Load(ctx)
	if len(recipes) == 0 {
		return
	}
	contents := make([]string, len(recipes))
	for i, r := range recipes {
		contents[i] = fmt.Sprintf("[%s] %s", r.Task, r.Content)
	}
	a.InjectRecipes(contents)
}

// postRunLearn extracts a recipe from the run and saves it to the store.
// Runs synchronously inside defer — the webhook can wrap the entire Run
// in a goroutine if latency is a concern.
func (a *Agent) postRunLearn(ctx context.Context) {
	if a.recipeStore == nil {
		return
	}
	learnCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()
	if recipe := a.ExtractRecipe(learnCtx); recipe != nil {
		a.recipeStore.Save(learnCtx, recipe)
	}
}

// RestoreMessages replaces the agent's message history (after the system prompt)
// with full llm.Message objects loaded from Redis. Used by the stateless webhook
// to reconstruct conversation context on each request.
func (a *Agent) RestoreMessages(msgs []llm.Message) {
	if len(msgs) == 0 {
		return
	}
	msgs = sanitizeOrphanedToolResults(msgs)
	a.messages = append(a.messages[:1], msgs...)
	a.checkpoint = len(a.messages)
	slog.Debug("agent: restored messages from redis",
		"restored", len(msgs),
		"total_messages", len(a.messages),
	)
}

// RestoreMessagesWithMemory restores messages from Redis and injects long-term
// memory as a system message. memory is the GCS-based summary for context
// that predates the Redis sliding window.
func (a *Agent) RestoreMessagesWithMemory(msgs []llm.Message, memory string) {
	if memory != "" {
		memMsg := llm.Message{
			Role:    "system",
			Content: fmt.Sprintf("[Long-term memory from previous sessions]\n%s", memory),
		}
		a.messages = append(a.messages[:1], memMsg)
	}
	if len(msgs) > 0 {
		msgs = sanitizeOrphanedToolResults(msgs)
		a.messages = append(a.messages, msgs...)
	}
	a.checkpoint = len(a.messages)
	slog.Debug("agent: restored messages with memory",
		"restored", len(msgs),
		"has_memory", memory != "",
		"total_messages", len(a.messages),
	)
}

// ExportNewMessages returns the new llm.Message objects added since the last
// RestoreMessages call (or since agent creation). These are the messages that
// should be RPUSH-ed to the Redis sliding window after each Run().
func (a *Agent) ExportNewMessages() []llm.Message {
	if a.checkpoint >= len(a.messages) {
		return nil
	}
	return a.messages[a.checkpoint:]
}

// ExportAllNonSystem returns all non-system-prompt messages for memory extraction.
// Includes restored messages + new messages — the full conversation window.
func (a *Agent) ExportAllNonSystem() []llm.Message {
	if len(a.messages) <= 1 {
		return nil
	}
	return a.messages[1:]
}

// Run processes a user message through the LLM + tool loop.
//
// Architecture: Opus decides everything, Haiku only summarizes tool output.
//
//	Opus (tools, streamed) → tool calls → execute → Haiku summarizes results →
//	Opus (tools, streamed) → tool calls or final answer → ...
func (a *Agent) Run(ctx context.Context, userMessage string) error {
	a.messages = append(a.messages, llm.Message{Role: "user", Content: userMessage})
	a.usedTools = make(map[string]bool)
	a.toolTrace = nil
	a.iterationCount = 0
	defer a.pruneIdleSkills()
	defer a.postRunLearn(ctx)
	slog.Debug("agent: run start", "message_count", len(a.messages), "user_message_len", len(userMessage))

	a.loadRecipes(ctx)
	a.autoActivateSkills(userMessage)

	for i := 0; i < maxIterations; i++ {
		a.manageContext(ctx)

		// PRIMARY MODEL makes ALL decisions — which tools, what params, when to stop.
		tools := a.tools
		if i == maxIterations-1 {
			tools = nil
			a.messages = append(a.messages, llm.Message{
				Role:    "system",
				Content: "You have reached the maximum number of tool-call iterations. Do NOT call any more tools. Instead, summarize what you have accomplished so far, what remains unfinished, and suggest next steps the user can take.",
			})
			slog.Debug("agent: max iterations reached, forcing final reply", "iteration", i+1)
		}

		toolCount := 0
		if tools != nil {
			toolCount = len(tools)
		}
		slog.Debug("agent: chatstream call",
			"iteration", i+1,
			"message_count", len(a.messages),
			"tools_available", toolCount,
		)

		msg, usage, err := a.llmClient.ChatStream(ctx, a.messages, tools, a.onContent)
		if err != nil {
			slog.Error("agent: chatstream failed", "iteration", i+1, "error", err)
			return fmt.Errorf("llm call failed: %w", err)
		}
		if usage != nil && a.onUsage != nil {
			a.onUsage(*usage)
		}

		slog.Debug("agent: chatstream done",
			"iteration", i+1,
			"content_len", len(msg.Content),
			"tool_calls", len(msg.ToolCalls),
		)

		if len(msg.ToolCalls) == 0 {
			// Retry once on completely empty response (some models occasionally
			// return an empty reply on the first call).
			if msg.Content == "" && i == 0 {
				slog.Debug("agent: empty response on first iteration, retrying")
				continue
			}
			a.messages = append(a.messages, *msg)
			a.iterationCount = i + 1
			slog.Debug("agent: run done", "iterations", i+1, "final_reply_len", len(msg.Content))
			return nil
		}

		// Intermediate content before tool execution — notify caller so it can
		// send progress to the user (e.g. WhatsApp) without waiting for the full run.
		if msg.Content != "" && a.onIterationContent != nil {
			a.onIterationContent(msg.Content)
		}

		a.messages = append(a.messages, *msg)

		// Execute tools in parallel, then collect results in order.
		toolResults := a.executeToolsConcurrently(ctx, msg.ToolCalls, i+1)
		a.messages = append(a.messages, toolResults...)

		slog.Debug("agent: iteration tools done", "iteration", i+1, "tool_calls", len(msg.ToolCalls))
		a.refreshTools()
	}

	slog.Debug("agent: run done (max iterations)", "iterations", maxIterations)
	return nil
}

// executeToolsConcurrently runs all tool calls in parallel and returns
// results in the same order as the original tool calls.
func (a *Agent) executeToolsConcurrently(ctx context.Context, toolCalls []llm.ToolCall, iteration int) []llm.Message {
	n := len(toolCalls)
	results := make([]llm.Message, n)
	var wg sync.WaitGroup
	wg.Add(n)

	for _, tc := range toolCalls {
		a.usedTools[tc.Function.Name] = true
	}

	for j := range toolCalls {
		go func(idx int) {
			defer wg.Done()
			tc := toolCalls[idx]

			var args map[string]interface{}
			if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
				args = map[string]interface{}{}
			}
			if a.apiKeyOverride != "" {
				args["api_key"] = a.apiKeyOverride
			}

			if a.onToolCall != nil {
				a.onToolCall(tc.Function.Name, args)
			}

			handler, ok := a.handlers[tc.Function.Name]
			if !ok {
				slog.Warn("agent: unknown tool", "tool", tc.Function.Name, "iteration", iteration, "call_index", idx+1)
				results[idx] = llm.Message{
					Role:       "tool",
					Content:    fmt.Sprintf("Error: unknown tool %s", tc.Function.Name),
					ToolCallID: tc.ID,
					Name:       tc.Function.Name,
				}
				return
			}

			result, err := handler(ctx, args)
			var resultStr string
			if err != nil {
				resultStr = fmt.Sprintf("Error: %s", err.Error())
				slog.Error("agent: tool exec failed",
					"tool", tc.Function.Name,
					"iteration", iteration,
					"call_index", idx+1,
					"error", err,
				)
			} else {
				b, _ := json.Marshal(result)
				resultStr = string(b)
				if len(resultStr) > maxToolResultFresh {
					resultStr = resultStr[:maxToolResultFresh] + fmt.Sprintf("…(%d chars total)", len(resultStr))
				}
				slog.Debug("agent: tool exec done",
					"tool", tc.Function.Name,
					"iteration", iteration,
					"call_index", idx+1,
					"result_len", len(resultStr),
				)
			}

			if a.onToolDone != nil {
				a.onToolDone(tc.Function.Name, truncate(resultStr, 200))
			}

			resultStr = a.maybeSummarize(ctx, tc.Function.Name, resultStr)

			results[idx] = llm.Message{
				Role:       "tool",
				Content:    resultStr,
				ToolCallID: tc.ID,
				Name:       tc.Function.Name,
			}
		}(j)
	}

	wg.Wait()

	for idx, r := range results {
		a.toolTrace = append(a.toolTrace, ToolTraceEntry{
			Iteration: iteration,
			Tool:      toolCalls[idx].Function.Name,
			Error:     strings.HasPrefix(r.Content, "Error:"),
			ResultLen: len(r.Content),
		})
	}

	return results
}

// intentSkillMap maps keywords (lowercased) to skill names that should be
// auto-activated when the user message contains them.
var intentSkillMap = []struct {
	keywords []string
	skills   []string
}{
	{[]string{"conversation", "messages", "chat history", "chats"}, []string{"conversations"}},
	{[]string{"analytics", "analyze", "metrics", "statistics", "data", "performance"}, []string{"analytics", "conversations"}},
	{[]string{"voice", "phone", "call", "calls"}, []string{"conversations", "analytics", "voice_agent", "voice_calls"}},
	{[]string{"lead", "prospect", "customer", "client"}, []string{"analytics", "conversations"}},
	{[]string{"test", "testing", "evaluate", "evaluation"}, []string{"testing"}},
	{[]string{"knowledge", "kb", "document", "documents"}, []string{"knowledge_management"}},
	{[]string{"integration", "connect", "connection"}, []string{"integrations"}},
	{[]string{"brand", "logo", "branding"}, []string{"branding"}},
	{[]string{"webhook"}, []string{"webhooks"}},
	{[]string{"template", "templates"}, []string{"templates"}},
}

// autoActivateSkills pre-activates skills based on user intent keywords,
// saving the LLM from spending an iteration on activate_skill calls.
func (a *Agent) autoActivateSkills(userMessage string) {
	activator, ok := a.skillPruner.(SkillActivator)
	if !ok || activator == nil {
		return
	}
	lower := strings.ToLower(userMessage)
	activated := make(map[string]bool)
	for _, entry := range intentSkillMap {
		for _, kw := range entry.keywords {
			if strings.Contains(lower, kw) {
				for _, sk := range entry.skills {
					if !activated[sk] {
						activator.ActivateIfInactive(sk)
						activated[sk] = true
					}
				}
				break
			}
		}
	}
	if len(activated) > 0 {
		a.refreshTools()
		names := make([]string, 0, len(activated))
		for sk := range activated {
			names = append(names, sk)
		}
		slog.Debug("agent: auto-activated skills by intent", "skills", names, "tools_after", len(a.tools))
	}
}

// pruneIdleSkills deactivates any active skills whose tools were not called
// during this Run(). Called via defer at the end of each Run.
func (a *Agent) pruneIdleSkills() {
	if a.skillPruner == nil || len(a.usedTools) == 0 {
		return
	}
	deactivated := a.skillPruner.DeactivateUnused(a.usedTools)
	if len(deactivated) > 0 {
		a.refreshTools()
		slog.Debug("agent: auto-deactivated idle skills",
			"deactivated", deactivated,
			"tools_after", len(a.tools),
		)
	}
}

// maybeSummarize uses the fast model to compress a tool result if it exceeds
// the summarize threshold. Short results and errors pass through unchanged.
func (a *Agent) maybeSummarize(ctx context.Context, toolName, result string) string {
	if a.fastClient == nil || len(result) < summarizeThreshold || strings.HasPrefix(result, "Error:") {
		return result
	}

	prompt := []llm.Message{
		{
			Role: "system",
			Content: `You are a data extraction assistant. Compress the following API tool result into a concise summary.
Rules:
- Preserve ALL UUIDs/IDs exactly as-is
- Preserve names, status values, numeric metrics, timestamps
- Preserve complete error messages if any
- For lists, state the count and include key fields of EACH item
- If the result contains instructions or long text fields, preserve their full content
- Output plain text, no markdown
- Be thorough — the decision-maker only sees your summary`,
		},
		{Role: "user", Content: fmt.Sprintf("Tool: %s\nResult:\n%s", toolName, result)},
	}

	msg, usage, err := a.fastClient.ChatStream(ctx, prompt, nil, nil)
	if err != nil {
		return result
	}
	if usage != nil && a.onUsage != nil {
		a.onUsage(*usage)
	}
	return fmt.Sprintf("[Summarized by fast model]\n%s", msg.Content)
}

func truncate(s string, maxLen int) string {
	if len(s) <= maxLen {
		return s
	}
	return s[:maxLen] + "..."
}

// ---- bridge: convert MCP server tools to LLM tools ----

func extractFromServer(srv *mcp.Server) ([]llm.Tool, map[string]mcp.ToolHandler) {
	defs, handlers := srv.ExportTools()

	var llmTools []llm.Tool
	for _, d := range defs {
		params := map[string]interface{}{
			"type": d.InputSchema.Type,
		}
		if len(d.InputSchema.Properties) > 0 {
			props := make(map[string]interface{})
			for k, p := range d.InputSchema.Properties {
				prop := map[string]interface{}{"type": p.Type}
				if p.Description != "" {
					prop["description"] = p.Description
				}
				if len(p.Enum) > 0 {
					prop["enum"] = p.Enum
				}
				props[k] = prop
			}
			params["properties"] = props
		}
		if len(d.InputSchema.Required) > 0 {
			params["required"] = d.InputSchema.Required
		}

		llmTools = append(llmTools, llm.Tool{
			Type: "function",
			Function: llm.Function{
				Name:        d.Name,
				Description: d.Description,
				Parameters:  params,
			},
		})
	}

	handlerMap := make(map[string]mcp.ToolHandler)
	for i, d := range defs {
		handlerMap[d.Name] = handlers[i]
	}

	return llmTools, handlerMap
}

// registerAllToolsOnServer calls the injected registration function.
func registerAllToolsOnServer(srv *mcp.Server, gw *gateway.Client) SkillPruner {
	if registerFunc != nil {
		return registerFunc(srv, gw)
	}
	return nil
}

// RegisterFunc type returns a SkillPruner so the agent can auto-deactivate idle skills.
var registerFunc func(srv *mcp.Server, gw *gateway.Client) SkillPruner

// SetRegisterFunc allows the CLI/webhook to inject the tool registration function.
func SetRegisterFunc(f func(srv *mcp.Server, gw *gateway.Client) SkillPruner) {
	registerFunc = f
}
