package context

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"gokin/internal/chat"
	"gokin/internal/client"
	"gokin/internal/config"
	"gokin/internal/logging"

	"google.golang.org/genai"
)

// Sentinel errors returned by ForceSummarize / OptimizeContext to let the
// caller distinguish "nothing to do" from a real failure. CompactCommand
// uses these to render specific user-facing messages (the prior behavior
// was a silent success that looked like the command did nothing).
var (
	// ErrSummarizerUnavailable: ContextManager has no summarizer wired (e.g.
	// summarization disabled in config, or provider didn't expose one).
	ErrSummarizerUnavailable = errors.New("summarizer not configured")
	// ErrHistoryTooShort: history has fewer messages than the strategy's
	// MinMessagesForSummary threshold. /compact is a no-op in this case —
	// summarizing a short history would lose more than it saves.
	ErrHistoryTooShort = errors.New("history too short to summarize")
	// ErrNothingToSummarize: the summary strategy produced an empty plan
	// (e.g., all messages are pinned or already-summarized markers).
	ErrNothingToSummarize = errors.New("no summarizable messages found")
)

// tokenSnapshot records token usage at a point in time for trend prediction.
type tokenSnapshot struct {
	tokens    int
	timestamp time.Time
}

// ContextManager orchestrates context management including token counting,
// auto-summarization, and optimization.
type ContextManager struct {
	ctx    context.Context
	cancel context.CancelFunc

	session      *chat.Session
	tokenCounter *TokenCounter
	summarizer   *Summarizer
	config       *config.ContextConfig
	client       client.Client

	mu            sync.RWMutex
	currentTokens int
	lastUsage     *TokenUsage
	updateVersion uint64 // Monotonically increasing version to prevent stale watcher updates

	// Async token counting
	lastEstimatedTokens int // Cached estimate for fast path
	lastHistoryLen      int // History length at last count

	// Background summarization
	summarizing    atomic.Bool   // Whether summarization is in progress (lock-free)
	summarizeDone  chan struct{} // Signal when summarization completes
	lastSummaryDur time.Duration // Duration of last summarization

	// New components
	metrics            *ContextMetrics
	summaryCache       *SummaryCache
	messageScorer      *MessageScorer
	summaryStrategy    SummaryStrategy
	responseCompressor *ResponseCompressor
	keyFiles           map[string]bool // Files critical to the session, always preserved
	tokenHistory       []tokenSnapshot // Token usage history for trend prediction

	// Semaphore to limit concurrent async token count goroutines
	tokenCountSem chan struct{}

	// Plan manager for task-aware summarization
	planManager PlanManagerProvider

	// Notification callback when context is compacted
	OnCompact func(oldTokens, newTokens, removedMessages int, reason string)

	// Notification callback when background optimization starts (for UI feedback)
	OnOptimizeStart func(reason string)
}

// NewContextManager creates a new context manager.
func NewContextManager(
	ctx context.Context,
	session *chat.Session,
	c client.Client,
	cfg *config.ContextConfig,
) *ContextManager {
	ctx, cancel := context.WithCancel(ctx)
	tokenCounter := NewTokenCounter(c, c.GetModel(), cfg)

	var summarizer *Summarizer
	if cfg != nil && cfg.EnableAutoSummary {
		summarizer = NewSummarizer(c)
	}

	// Initialize new components
	metrics := NewContextMetrics()
	summaryCache := NewSummaryCache(100, 30*time.Minute)
	messageScorer := NewMessageScorer()
	summaryStrategy := DefaultSummaryStrategy()

	// Configure response compressor
	maxToolChars := 10000
	if cfg != nil && cfg.ToolResultMaxChars > 0 {
		maxToolChars = cfg.ToolResultMaxChars
	}
	responseCompressor := NewResponseCompressor(maxToolChars)

	// Customize strategy from config
	if cfg != nil {
		if cfg.SummarizationRatio > 0 {
			summaryStrategy.TargetRatio = cfg.SummarizationRatio
		}
		if cfg.MaxInputTokens > 0 {
			summaryStrategy.MaxHistorySize = cfg.MaxInputTokens / 2000 // Rough estimate
		}
	}

	// Enable semantic scoring when auto-summary is available (requires LLM)
	if cfg != nil && cfg.EnableAutoSummary && c != nil {
		messageScorer.SetSemanticClient(c)
	}

	return &ContextManager{
		ctx:                ctx,
		cancel:             cancel,
		session:            session,
		tokenCounter:       tokenCounter,
		summarizer:         summarizer,
		config:             cfg,
		client:             c,
		metrics:            metrics,
		summaryCache:       summaryCache,
		messageScorer:      messageScorer,
		summaryStrategy:    summaryStrategy,
		responseCompressor: responseCompressor,
		keyFiles:           make(map[string]bool),
		tokenHistory:       make([]tokenSnapshot, 0, 20),
		tokenCountSem:      make(chan struct{}, 3),
	}
}

// SetPlanManager sets the plan manager for task-aware summarization.
func (m *ContextManager) SetPlanManager(pm PlanManagerProvider) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.planManager = pm
}

// syncTaskContext extracts current task info from the plan manager
// and updates the summarizer's task context.
func (m *ContextManager) syncTaskContext() {
	if m.summarizer == nil {
		return
	}
	m.mu.RLock()
	pm := m.planManager
	m.mu.RUnlock()

	if pm == nil {
		m.summarizer.SetTaskContext(nil)
		return
	}

	contract := pm.GetActiveContractContext()
	if contract == "" {
		m.summarizer.SetTaskContext(nil)
		return
	}

	// Parse the contract text into structured TaskContext
	tc := &TaskContext{}
	for line := range strings.SplitSeq(contract, "\n") {
		line = strings.TrimSpace(line)
		switch {
		case strings.HasPrefix(line, "**Plan:**"):
			tc.Title = strings.TrimSpace(strings.TrimPrefix(line, "**Plan:**"))
		case strings.HasPrefix(line, "Plan:"):
			tc.Title = strings.TrimSpace(strings.TrimPrefix(line, "Plan:"))
		case strings.HasPrefix(line, "**Goal:**"):
			tc.Description = strings.TrimSpace(strings.TrimPrefix(line, "**Goal:**"))
		case strings.HasPrefix(line, "Goal:"):
			tc.Description = strings.TrimSpace(strings.TrimPrefix(line, "Goal:"))
		case strings.HasPrefix(line, "**Current step:**"):
			tc.CurrentStep = strings.TrimSpace(strings.TrimPrefix(line, "**Current step:**"))
		case strings.HasPrefix(line, "Current step:"):
			tc.CurrentStep = strings.TrimSpace(strings.TrimPrefix(line, "Current step:"))
		case strings.HasPrefix(line, "- ") && tc.CurrentStep != "":
			tc.SuccessCriteria = append(tc.SuccessCriteria, strings.TrimPrefix(line, "- "))
		}
	}

	if tc.Title == "" && tc.Description == "" {
		// Fallback: use the raw contract text as description
		tc.Description = contract
		if runes := []rune(tc.Description); len(runes) > 500 {
			tc.Description = string(runes[:500]) + "..."
		}
	}

	m.summarizer.SetTaskContext(tc)
}

// SetClient updates the underlying client for token counting and summarization.
func (m *ContextManager) SetClient(c client.Client) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.client = c
	m.tokenCounter.SetClient(c)
	if m.summarizer != nil {
		m.summarizer.SetClient(c)
	}
	if m.messageScorer != nil {
		m.messageScorer.SetSemanticClient(c)
	}
}

// SetConfig updates the context configuration.
func (m *ContextManager) SetConfig(cfg *config.ContextConfig) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.config = cfg
	if m.summarizer != nil {
		m.summarizer.SetConfig(cfg)
	}
}

// StartSessionWatcher starts monitoring session changes for auto-updating token counts.
func (m *ContextManager) StartSessionWatcher() {
	m.session.SetChangeHandler(m.onSessionChange)
}

// onSessionChange is called when session history changes.
func (m *ContextManager) onSessionChange(event chat.ChangeEvent) {
	// Invalidate token cache when history changes
	m.tokenCounter.InvalidateCache()

	// Track key files from new messages
	if event.NewCount > event.OldCount {
		m.trackKeyFiles(event)
	}

	// Update token count asynchronously
	go func() {
		defer func() {
			if r := recover(); r != nil {
				logging.Error("panic in session change handler", "error", r)
			}
		}()

		m.mu.RLock()
		versionBefore := m.updateVersion
		m.mu.RUnlock()

		tctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
		defer cancel()

		history := m.session.GetHistory()
		tokens, err := m.tokenCounter.CountContents(tctx, history)
		isEstimate := false
		if err != nil {
			tokens = EstimateContentsTokens(history)
			isEstimate = true
			logging.Debug("failed to count tokens on session change, using estimate", "error", err)
		}

		m.mu.Lock()
		if m.updateVersion == versionBefore {
			m.currentTokens = tokens
			usage := m.tokenCounter.GetUsage(tokens)
			usage.IsEstimate = isEstimate
			m.lastUsage = &usage
		}
		m.mu.Unlock()

		// Auto-compact if threshold exceeded
		m.tryAutoCompact(tctx, tokens)
	}()
}

// PrepareForRequest prepares the context before sending a request.
// Uses fast estimation for immediate decisions; precise count runs async.
func (m *ContextManager) PrepareForRequest(ctx context.Context) error {
	startTime := time.Now()

	history := m.session.GetHistory()

	// Compress large function responses
	history = m.responseCompressor.CompressContents(history)

	// Fast path: use estimation if history delta is small (< 5 messages since last count)
	m.mu.RLock()
	historyDelta := len(history) - m.lastHistoryLen
	cachedTokens := m.lastEstimatedTokens
	m.mu.RUnlock()

	var tokens int
	isEstimate := false

	if cachedTokens > 0 && historyDelta >= 0 && historyDelta < 5 {
		// Use cached estimate + delta estimation for speed
		tokens = cachedTokens + EstimateContentsTokens(history[len(history)-max(historyDelta, 0):])
		isEstimate = true
		m.metrics.RecordEstimation()
	} else {
		// Need full count - use estimate first, then async precise count
		tokens = EstimateContentsTokens(history)
		isEstimate = true
		m.metrics.RecordEstimation()
	}

	m.mu.Lock()
	m.updateVersion++
	m.currentTokens = tokens
	m.lastEstimatedTokens = tokens
	m.lastHistoryLen = len(history)
	usage := m.tokenCounter.GetUsage(tokens)
	usage.IsEstimate = isEstimate
	m.lastUsage = &usage
	m.tokenHistory = append(m.tokenHistory, tokenSnapshot{tokens: tokens, timestamp: time.Now()})
	if len(m.tokenHistory) > 20 {
		m.tokenHistory = m.tokenHistory[len(m.tokenHistory)-20:]
	}
	m.mu.Unlock()

	// Emergency: if estimated tokens EXCEED limit, truncate synchronously (no LLM needed)
	if usage.ExceedsLimit {
		logging.Warn("context exceeds token limit, performing emergency truncation",
			"tokens", tokens, "limit", m.tokenCounter.limits.MaxInputTokens)
		m.EmergencyTruncate()
		// Re-estimate after truncation
		history = m.session.GetHistory()
		tokens = EstimateContentsTokens(history)
		m.mu.Lock()
		m.currentTokens = tokens
		m.lastEstimatedTokens = tokens
		m.lastHistoryLen = len(history)
		usage = m.tokenCounter.GetUsage(tokens)
		m.lastUsage = &usage
		m.mu.Unlock()
	}

	// Launch async precise token count in background (bounded by semaphore)
	select {
	case m.tokenCountSem <- struct{}{}:
		go func() {
			defer func() { <-m.tokenCountSem }()
			defer func() {
				if r := recover(); r != nil {
					logging.Error("panic in async token count", "error", r)
				}
			}()

			asyncCtx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
			defer cancel()

			precise, err := m.tokenCounter.CountContents(asyncCtx, history)
			if err != nil {
				return // Keep using estimate
			}
			m.metrics.RecordAPICount()

			m.mu.Lock()
			m.lastEstimatedTokens = precise
			m.currentTokens = precise
			u := m.tokenCounter.GetUsage(precise)
			u.IsEstimate = false
			m.lastUsage = &u
			m.mu.Unlock()
		}()
	default:
		// Semaphore full, skip async count — use estimate
	}

	// Record metrics
	m.metrics.RecordPrepare(time.Since(startTime), tokens)

	// Optimize if near limit — launch in background to avoid blocking
	if usage.NearLimit && m.summarizer != nil && m.config.EnableAutoSummary {
		m.notifyOptimizeStart("context near token limit")
		m.backgroundOptimize(ctx)
	}

	// Predictive: check if next request will exceed limit based on trend
	if !usage.NearLimit && m.summarizer != nil && m.config.EnableAutoSummary {
		if predicted := m.predictTokensAfterRequest(); predicted > 0 {
			predUsage := m.tokenCounter.GetUsage(predicted)
			if predUsage.PercentUsed > 0.85 {
				logging.Info("predictive summarization triggered",
					"current_tokens", tokens,
					"predicted_tokens", predicted,
					"predicted_pct", predUsage.PercentUsed)
				m.notifyOptimizeStart("predictive — approaching token limit")
				m.backgroundOptimize(ctx)
			}
		}
	}

	// Proactive summarization when approaching message limit (sliding window)
	if m.summarizer != nil && m.config.EnableAutoSummary {
		maxHistory := config.DefaultMaxSessionHistory
		if historyLen := len(history); historyLen > maxHistory*3/4 {
			logging.Info("message count summarization triggered",
				"history_len", historyLen,
				"max_history", maxHistory)
			m.notifyOptimizeStart("conversation history growing large")
			m.backgroundOptimize(ctx)
		}
	}

	return nil
}

// notifyOptimizeStart fires the OnOptimizeStart callback if registered.
func (m *ContextManager) notifyOptimizeStart(reason string) {
	if m.OnOptimizeStart != nil {
		m.OnOptimizeStart(reason)
	}
}

// backgroundOptimize runs context optimization in a background goroutine.
// Only one optimization runs at a time; concurrent calls are no-ops.
func (m *ContextManager) backgroundOptimize(_ context.Context) {
	// Atomic CAS prevents concurrent summarization without holding the mutex
	if !m.summarizing.CompareAndSwap(false, true) {
		return // Already running
	}

	done := make(chan struct{})
	m.mu.Lock()
	m.summarizeDone = done
	m.mu.Unlock()

	go func() {
		defer func() {
			if r := recover(); r != nil {
				logging.Error("panic in background optimization", "error", r)
			}
			m.summarizing.Store(false)
			close(done)
		}()

		// Use the manager's lifecycle context, not the request context.
		// The request context is cancelled when the API call returns, but
		// background summarization may still be running — using the request
		// ctx causes it to abort prematurely on fast responses.
		sumCtx, cancel := context.WithTimeout(m.ctx, 60*time.Second)
		defer cancel()

		start := time.Now()
		if err := m.OptimizeContext(sumCtx); err != nil {
			logging.Warn("background context optimization failed", "error", err)
		}
		m.mu.Lock()
		m.lastSummaryDur = time.Since(start)
		m.mu.Unlock()
	}()
}

// OptimizeContext optimizes the context by summarizing old messages.
func (m *ContextManager) OptimizeContext(ctx context.Context) error {
	startTime := time.Now()

	// Snapshot strategy under read-lock — SetSummaryStrategy is concurrent
	// write under m.mu.Lock(), so direct field reads here would race
	// (caught by `go test -race` if a strategy swap lands mid-compact).
	m.mu.RLock()
	strategy := m.summaryStrategy
	m.mu.RUnlock()

	history := m.session.GetHistory()
	if len(history) <= strategy.MinMessagesForSummary {
		// Not enough messages to summarize. Surface this so /compact can
		// tell the user "you only have N messages, threshold is M" instead
		// of silently succeeding with zero tokens freed.
		return ErrHistoryTooShort
	}

	// Sync task context for task-aware summarization
	m.syncTaskContext()

	// Create summary plan using strategy
	plan := CreateSummaryPlan(history, strategy, m.messageScorer)

	if len(plan.ToSummarize) == 0 {
		// All messages are pinned or already-summarized. Surface so the
		// caller can explain the no-op rather than reporting bogus success.
		return ErrNothingToSummarize
	}

	// Check cache first
	messageHash := HashMessages(plan.ToSummarize)
	cachedSummary, found := m.summaryCache.Get(messageHash)

	var summary *genai.Content
	var fromCache bool

	if found {
		// Use cached summary
		summary = cachedSummary.Summary
		fromCache = true
		logging.Debug("using cached summary", "tokens", cachedSummary.TokenCount)
	} else {
		// Generate new summary
		startSummary := time.Now()
		var err error
		summary, err = m.summarizer.Summarize(ctx, plan.ToSummarize)
		if err != nil {
			return err
		}

		// Count summary tokens
		summaryTokens, err := m.tokenCounter.CountContents(ctx, []*genai.Content{summary})
		if err != nil {
			summaryTokens = EstimateContentsTokens([]*genai.Content{summary})
		}

		// Cache the summary
		originalTokens := EstimateContentsTokens(plan.ToSummarize)
		m.summaryCache.Put(messageHash, summary, summaryTokens, 0, 0)

		// Record metrics
		m.metrics.RecordSummary(time.Since(startSummary), summaryTokens, originalTokens, false)
		fromCache = false
	}

	// Apply the summary plan
	newHistory := ApplySummaryPlan(plan, summary)
	m.session.SetHistory(newHistory)

	// Recount tokens
	tokens, err := m.tokenCounter.CountContents(ctx, newHistory)
	if err != nil {
		tokens = EstimateContentsTokens(newHistory)
	}

	m.mu.Lock()
	m.currentTokens = tokens
	usage := m.tokenCounter.GetUsage(tokens)
	m.lastUsage = &usage
	m.mu.Unlock()

	// Record optimization metrics
	oldTokens := EstimateContentsTokens(history)
	m.metrics.RecordOptimize(time.Since(startTime), oldTokens, tokens)

	logging.Info("context optimized",
		"before", oldTokens,
		"after", tokens,
		"saved", oldTokens-tokens,
		"from_cache", fromCache)

	if m.OnCompact != nil {
		m.OnCompact(oldTokens, tokens, len(history)-len(newHistory), "Full Strategy Compaction")
	}

	return nil
}

// GetContextHealth returns a snapshot of context health.
func (m *ContextManager) GetContextHealth() (int, int, float64) {
	m.mu.RLock()
	defer m.mu.RUnlock()

	total := m.currentTokens
	max := 0
	if m.tokenCounter != nil {
		max = m.tokenCounter.GetLimits().MaxInputTokens
	}

	percent := 0.0
	if max > 0 {
		percent = float64(total) / float64(max)
	}

	return total, max, percent
}

// GetTokenUsage returns the current token usage.
func (m *ContextManager) GetTokenUsage() *TokenUsage {
	m.mu.RLock()
	defer m.mu.RUnlock()

	if m.lastUsage == nil {
		return &TokenUsage{}
	}
	return m.lastUsage
}

// GetCurrentTokens returns the current token count.
func (m *ContextManager) GetCurrentTokens() int {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.currentTokens
}

// UpdateTokenCount updates the token count after a request.
func (m *ContextManager) UpdateTokenCount(ctx context.Context) error {
	history := m.session.GetHistory()

	tokens, err := m.tokenCounter.CountContents(ctx, history)
	isEstimate := false
	if err != nil {
		tokens = EstimateContentsTokens(history)
		isEstimate = true
	}

	m.mu.Lock()
	m.updateVersion++ // Invalidate any in-flight watcher updates
	m.currentTokens = tokens
	usage := m.tokenCounter.GetUsage(tokens)
	usage.IsEstimate = isEstimate
	m.lastUsage = &usage
	m.mu.Unlock()

	return nil
}

// NeedsSummarization checks if the context needs summarization.
func (m *ContextManager) NeedsSummarization() bool {
	m.mu.RLock()
	defer m.mu.RUnlock()

	if m.lastUsage == nil {
		return false
	}
	return m.lastUsage.NearLimit
}

// GetTokenCounter returns the underlying token counter.
func (m *ContextManager) GetTokenCounter() *TokenCounter {
	return m.tokenCounter
}

// GetMetrics returns the context metrics.
func (m *ContextManager) GetMetrics() *ContextMetrics {
	return m.metrics
}

// GetCacheStats returns summary cache statistics.
func (m *ContextManager) GetCacheStats() CacheStats {
	return m.summaryCache.GetStats()
}

// SetSummaryStrategy sets a new summarization strategy.
func (m *ContextManager) SetSummaryStrategy(strategy SummaryStrategy) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.summaryStrategy = strategy
}

// GetSummaryStrategy returns the current summarization strategy.
func (m *ContextManager) GetSummaryStrategy() SummaryStrategy {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.summaryStrategy
}

// predictTokensAfterRequest estimates token count after the next request
// based on the trend of recent token snapshots.
func (m *ContextManager) predictTokensAfterRequest() int {
	m.mu.RLock()
	history := make([]tokenSnapshot, len(m.tokenHistory))
	copy(history, m.tokenHistory)
	m.mu.RUnlock()

	if len(history) < 3 {
		return 0 // Not enough data to predict
	}

	// Calculate average token growth per request
	totalGrowth := 0
	count := 0
	for i := 1; i < len(history); i++ {
		growth := history[i].tokens - history[i-1].tokens
		if growth > 0 {
			totalGrowth += growth
			count++
		}
	}

	if count == 0 {
		return 0
	}

	avgGrowth := totalGrowth / count
	current := history[len(history)-1].tokens

	// Predict: current + average growth for next request
	return current + avgGrowth
}

// ForceSummarize forces context summarization regardless of token count.
func (m *ContextManager) ForceSummarize(ctx context.Context) error {
	if m.summarizer == nil {
		return ErrSummarizerUnavailable
	}
	return m.OptimizeContext(ctx)
}

// trackKeyFiles extracts file paths from session changes to track critical files.
func (m *ContextManager) trackKeyFiles(event chat.ChangeEvent) {
	history := m.session.GetHistory()
	if len(history) == 0 {
		return
	}

	m.mu.Lock()
	defer m.mu.Unlock()

	// Scan newly added messages (from OldCount to NewCount)
	start := max(event.OldCount, 0)
	if start >= len(history) {
		return
	}

	for i := start; i < len(history); i++ {
		content := history[i]
		if content == nil {
			continue
		}
		for _, part := range content.Parts {
			if part.FunctionCall != nil {
				if path, ok := part.FunctionCall.Args["path"].(string); ok {
					m.keyFiles[path] = true
				}
				if path, ok := part.FunctionCall.Args["file_path"].(string); ok {
					m.keyFiles[path] = true
				}
			}
		}
	}
}

// tryAutoCompact triggers incremental compaction when tokens exceed the configured threshold.
func (m *ContextManager) tryAutoCompact(ctx context.Context, currentTokens int) {
	if m.summarizer == nil || m.config == nil || !m.config.EnableAutoSummary {
		return
	}

	threshold := m.config.AutoCompactThreshold
	if threshold <= 0 {
		threshold = 0.75 // default
	}

	usage := m.tokenCounter.GetUsage(currentTokens)
	if usage.PercentUsed < threshold {
		return
	}

	logging.Info("auto-compaction triggered",
		"tokens", currentTokens,
		"percentage", usage.PercentUsed,
		"threshold", threshold)

	if err := m.IncrementalCompact(ctx); err != nil {
		logging.Warn("auto-compaction failed", "error", err)
	}
}

// IncrementalCompact performs incremental compaction: summarizes oldest messages first,
// preserves recent messages and key file references.
func (m *ContextManager) IncrementalCompact(ctx context.Context) error {
	// Sync task context for task-aware summarization
	m.syncTaskContext()

	// Snapshot strategy under read-lock — see OptimizeContext for rationale.
	m.mu.RLock()
	strategy := m.summaryStrategy
	m.mu.RUnlock()

	history := m.session.GetHistory()

	// Preserve last 50 messages in full fidelity
	preserveCount := min(50, len(history))

	if len(history) <= preserveCount {
		return nil // Nothing old enough to compact
	}

	// Split: old messages to summarize, recent to preserve
	// Adjust boundary so FunctionCall/FunctionResponse pairs are not split
	splitPoint := AdjustBoundaryForToolPairs(history, len(history)-preserveCount)
	oldMessages := history[:splitPoint]
	recentMessages := history[splitPoint:]

	if len(oldMessages) < strategy.MinMessagesForSummary {
		return nil
	}

	// Summarize old messages
	summary, err := m.summarizer.Summarize(ctx, oldMessages)
	if err != nil {
		return fmt.Errorf("incremental compaction failed: %w", err)
	}

	// Build new history: summary + recent
	newHistory := make([]*genai.Content, 0, 1+len(recentMessages))
	newHistory = append(newHistory, summary)
	newHistory = append(newHistory, recentMessages...)

	m.session.SetHistory(newHistory)

	// Update token count
	tokens, err := m.tokenCounter.CountContents(ctx, newHistory)
	if err != nil {
		tokens = EstimateContentsTokens(newHistory)
	}

	m.mu.Lock()
	m.currentTokens = tokens
	usage := m.tokenCounter.GetUsage(tokens)
	m.lastUsage = &usage
	m.mu.Unlock()

	oldTokens := EstimateContentsTokens(history)
	logging.Info("incremental compaction done",
		"before", oldTokens,
		"after", tokens,
		"messages_summarized", len(oldMessages),
		"messages_preserved", len(recentMessages))

	if m.OnCompact != nil {
		m.OnCompact(oldTokens, tokens, len(history)-len(newHistory), "Incremental Compaction")
	}

	return nil
}

// EmergencyTruncate performs hard truncation of history when token count exceeds the model limit.
// Unlike summarization, this is synchronous and guaranteed to fit within budget.
//
// Strategy: keep first 2 messages (system context) + a recent tail (continuity)
// + the highest-IMPORTANCE messages rescued from the older region that the
// recent-tail cut would otherwise drop wholesale. Pre-v0.86.6 this was
// recency-only — an old-but-critical tool result or error was discarded purely
// for being old, even though the budget had room. Now a slice of the budget is
// reserved to rescue scored middle messages (errors/edits/tool results rank
// highest via MessageScorer), so the survivor set is "recent + important", not
// just "recent". Mirrors the agent's forceCompactViaTruncation intent while
// keeping the emergency path's budget guarantee (it fires precisely because we
// blew the limit, so it must produce a result that fits).
func (m *ContextManager) EmergencyTruncate() int {
	history := m.session.GetHistory()
	if len(history) <= 4 {
		return 0
	}

	targetTokens := int(float64(m.tokenCounter.limits.MaxInputTokens) * 0.7)

	// Nothing to do if the whole conversation already fits the budget. (Counts
	// preserved too, since those messages are also kept against the budget.)
	if EstimateContentsTokens(history) <= targetTokens {
		return 0
	}

	// Always preserve first 2 messages (system/greeting) and build from the end.
	preserved := history[:2]
	tail := history[2:]

	// Budget split: the recent tail gets the bulk (continuity matters most for
	// resuming the task); reserve ~25% of the remaining budget to rescue the
	// most important older messages that recency alone would drop.
	budget := max(0, targetTokens-EstimateContentsTokens(preserved))
	rescueBudget := budget / 4
	recencyBudget := budget - rescueBudget

	// Scan from newest to oldest within the recency budget, accumulating until
	// the next message would overflow it.
	tailTokens := 0
	cutoff := len(tail)
	for i := len(tail) - 1; i >= 0; i-- {
		msgTokens := EstimateContentsTokens([]*genai.Content{tail[i]})
		if tailTokens+msgTokens > recencyBudget {
			cutoff = i + 1
			break
		}
		tailTokens += msgTokens
	}

	// Adjust boundary so FunctionCall/FunctionResponse pairs are not split
	cutoff = AdjustBoundaryForToolPairs(tail, cutoff)

	recencyTail := tail[cutoff:]
	dropped := tail[:cutoff]

	// Rescue the highest-importance messages from the dropped region within the
	// reserved budget, returned in chronological order.
	rescued := m.selectImportantWithinBudget(dropped, rescueBudget)

	// Re-grounding: emergency truncation keeps only history[:2] + the rescued
	// middle + the recent tail, so the original task (~history[2]) and the
	// working file-set may get dropped — the model then drifts off-task. Splice
	// a concise re-grounding note where the task used to sit so it survives.
	regrounding := m.buildRegroundingNote(history)

	newHistory := make([]*genai.Content, 0, len(preserved)+1+len(rescued)+len(recencyTail))
	newHistory = append(newHistory, preserved...)
	if regrounding != nil {
		newHistory = append(newHistory, regrounding)
	}
	newHistory = append(newHistory, rescued...)
	newHistory = append(newHistory, recencyTail...)

	// Rescued messages are non-contiguous picks, so a rescued FunctionResponse
	// whose FunctionCall wasn't rescued (or vice versa) would be orphaned —
	// strict providers 400 on that. Prune orphaned tool parts as a final pass.
	newHistory = pruneOrphanedToolParts(newHistory)

	m.session.SetHistory(newHistory)

	// Update cached token count — save old value BEFORE overwriting for the callback
	tokens := EstimateContentsTokens(newHistory)
	m.mu.Lock()
	oldTokensEstimate := m.lastEstimatedTokens
	m.currentTokens = tokens
	usage := m.tokenCounter.GetUsage(tokens)
	m.lastUsage = &usage
	m.lastEstimatedTokens = tokens
	m.lastHistoryLen = len(newHistory)
	m.mu.Unlock()

	removed := len(history) - len(newHistory)
	logging.Warn("emergency truncation performed",
		"removed", removed,
		"before", len(history),
		"after", len(newHistory),
		"estimated_tokens", tokens)

	if m.OnCompact != nil {
		m.OnCompact(oldTokensEstimate, tokens, removed, "Emergency Truncation (Limits hit)")
	}

	return removed
}

// buildRegroundingNote synthesizes a compact reminder of the original task and
// the working file-set, injected after a hard truncation so the model keeps the
// thread. Returns nil when no task message is found (nothing useful to say).
func (m *ContextManager) buildRegroundingNote(history []*genai.Content) *genai.Content {
	// Original task = the first non-empty user message in the conversation.
	task := ""
	for _, c := range history {
		if c == nil || c.Role != genai.RoleUser {
			continue
		}
		for _, p := range c.Parts {
			if p != nil && strings.TrimSpace(p.Text) != "" {
				task = p.Text
				break
			}
		}
		if task != "" {
			break
		}
	}
	if task == "" {
		return nil
	}
	if r := []rune(task); len(r) > 600 {
		task = string(r[:600]) + "…"
	}

	var sb strings.Builder
	sb.WriteString("[Context was truncated to fit the model's limit — re-grounding so you keep the thread.]\n\n")
	sb.WriteString("Original task: ")
	sb.WriteString(task)

	if files := m.GetKeyFiles(); len(files) > 0 {
		sort.Strings(files)
		if len(files) > 12 {
			files = files[:12]
		}
		sb.WriteString("\n\nKey files touched this session: ")
		sb.WriteString(strings.Join(files, ", "))
	}
	sb.WriteString("\n\nContinue from where you left off. Re-read a file if you need its current contents.")

	return genai.NewContentFromText(sb.String(), genai.RoleUser)
}

// selectImportantWithinBudget ranks the given messages by importance
// (MessageScorer: errors/system → Critical, edits → High, verbose reads → Low)
// and greedily keeps the highest-scored ones whose token cost fits the budget,
// returning them in chronological order so the spliced history stays ordered.
// Used by EmergencyTruncate to rescue valuable older messages instead of
// dropping the whole middle by age. Returns nil when there's no budget, no
// scorer, or no messages.
func (m *ContextManager) selectImportantWithinBudget(msgs []*genai.Content, budget int) []*genai.Content {
	if budget <= 0 || len(msgs) == 0 || m.messageScorer == nil {
		return nil
	}

	type scoredMsg struct {
		idx    int
		score  float64
		tokens int
		msg    *genai.Content
	}

	items := make([]scoredMsg, 0, len(msgs))
	for i, msg := range msgs {
		if msg == nil {
			continue
		}
		s := m.messageScorer.ScoreMessage(msg)
		items = append(items, scoredMsg{
			idx:    i,
			score:  float64(s.Priority) + s.Score,
			tokens: EstimateContentsTokens([]*genai.Content{msg}),
			msg:    msg,
		})
	}

	// Highest score first; stable on original index for ties.
	sort.SliceStable(items, func(a, b int) bool {
		if items[a].score != items[b].score {
			return items[a].score > items[b].score
		}
		return items[a].idx < items[b].idx
	})

	picked := make([]scoredMsg, 0, len(items))
	used := 0
	for _, it := range items {
		if used+it.tokens > budget {
			continue // try a smaller one — a single huge message shouldn't block the rest
		}
		used += it.tokens
		picked = append(picked, it)
	}

	// Restore chronological order for splicing.
	sort.SliceStable(picked, func(a, b int) bool {
		return picked[a].idx < picked[b].idx
	})

	out := make([]*genai.Content, 0, len(picked))
	for _, it := range picked {
		out = append(out, it.msg)
	}
	return out
}

// Close cancels the lifecycle context, stopping any background goroutines.
func (m *ContextManager) Close() {
	m.cancel()
}

// GetKeyFiles returns the set of files tracked as critical to the session.
func (m *ContextManager) GetKeyFiles() []string {
	m.mu.RLock()
	defer m.mu.RUnlock()
	files := make([]string, 0, len(m.keyFiles))
	for f := range m.keyFiles {
		files = append(files, f)
	}
	return files
}
