package api

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/nugget/thane-ai-agent/internal/model/router"
	"github.com/nugget/thane-ai-agent/internal/platform/logging"
	"github.com/nugget/thane-ai-agent/internal/runtime/agent"
	"github.com/nugget/thane-ai-agent/internal/state/memory"
)

// OllamaChatRequest is the Ollama /api/chat request format.
type OllamaChatRequest struct {
	Model     string              `json:"model"`
	Messages  []OllamaChatMessage `json:"messages"`
	Stream    *bool               `json:"stream,omitempty"`
	Options   map[string]any      `json:"options,omitempty"`
	Format    string              `json:"format,omitempty"`
	Tools     []map[string]any    `json:"tools,omitempty"`
	Think     bool                `json:"think,omitempty"`
	KeepAlive string              `json:"keep_alive,omitempty"`
}

// OllamaChatMessage is the Ollama message format.
type OllamaChatMessage struct {
	Role    string   `json:"role"`
	Content string   `json:"content"`
	Images  []string `json:"images,omitempty"`
}

// OllamaChatResponse is the Ollama /api/chat response format.
type OllamaChatResponse struct {
	Model              string            `json:"model"`
	CreatedAt          string            `json:"created_at"`
	Message            OllamaChatMessage `json:"message"`
	Done               bool              `json:"done"`
	DoneReason         string            `json:"done_reason,omitempty"`
	TotalDuration      int64             `json:"total_duration,omitempty"`
	LoadDuration       int64             `json:"load_duration,omitempty"`
	PromptEvalCount    int               `json:"prompt_eval_count,omitempty"`
	PromptEvalDuration int64             `json:"prompt_eval_duration,omitempty"`
	EvalCount          int               `json:"eval_count,omitempty"`
	EvalDuration       int64             `json:"eval_duration,omitempty"`
}

// OllamaTagsResponse is the Ollama /api/tags response format.
type OllamaTagsResponse struct {
	Models []OllamaModel `json:"models"`
}

// OllamaModel represents a model in the tags response.
type OllamaModel struct {
	Name       string            `json:"name"`
	Model      string            `json:"model"`
	ModifiedAt string            `json:"modified_at"`
	Size       int64             `json:"size"`
	Digest     string            `json:"digest"`
	Details    OllamaModelDetail `json:"details"`
}

// OllamaModelDetail contains model details.
type OllamaModelDetail struct {
	ParentModel       string   `json:"parent_model"`
	Format            string   `json:"format"`
	Family            string   `json:"family"`
	Families          []string `json:"families"`
	ParameterSize     string   `json:"parameter_size"`
	QuantizationLevel string   `json:"quantization_level"`
}

// OllamaVersionResponse is the Ollama /api/version response.
type OllamaVersionResponse struct {
	Version string `json:"version"`
}

// RegisterOllamaRoutes adds Ollama-compatible API endpoints to the mux.
// Use this for single-port setups. For dual-port, use OllamaServer instead.
func (s *Server) RegisterOllamaRoutes(mux *http.ServeMux) {
	mux.HandleFunc("POST /api/chat", func(w http.ResponseWriter, r *http.Request) {
		handleOllamaChatShared(w, r, s.loop, s.owuTracker, s.logger)
	})
	mux.HandleFunc("GET /api/tags", func(w http.ResponseWriter, r *http.Request) {
		handleOllamaTagsShared(w, r, s.logger)
	})
	mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
		handleOllamaVersionShared(w, r, s.logger)
	})
}

// handleOllamaChatShared handles the Ollama /api/chat endpoint.
// This is a shared implementation used by both OllamaServer and embedded routes.
//
// The function:
//   - Validates and parses the incoming Ollama chat request
//   - Sanitizes Home Assistant-specific content (tools, system prompts)
//   - Converts Ollama message format to agent message format
//   - Delegates to streaming or non-streaming handlers based on request
//   - Logs request details including client information
//
// Parameters:
//   - w: HTTP response writer
//   - r: HTTP request containing the chat payload
//   - loop: Agent loop for processing the conversation
//   - logger: Logger for request tracking and debugging
func handleOllamaChatShared(w http.ResponseWriter, r *http.Request, loop *agent.Loop, tracker *OWUTracker, logger *slog.Logger) {
	// Inject subsystem logger into context for downstream propagation.
	r = r.WithContext(logging.WithLogger(r.Context(), logger.With("subsystem", logging.SubsystemAPI)))

	start := time.Now()

	// Capture raw request for parsing; headers/body logged at debug level only
	headers := make(map[string]string)
	for k, v := range r.Header {
		headers[k] = v[0]
		if len(v) > 1 {
			headers[k] = fmt.Sprintf("%v", v)
		}
	}

	rawBody, err := captureBody(r)
	if err != nil {
		ollamaError(w, http.StatusBadRequest, "failed to read body")
		return
	}
	var req OllamaChatRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		ollamaError(w, http.StatusBadRequest, "invalid request body")
		return
	}

	// Derive real client IP from reverse proxy headers
	remoteIP := r.RemoteAddr
	if xri := r.Header.Get("X-Real-Ip"); xri != "" {
		remoteIP = xri
	} else if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
		remoteIP = xff
	}

	logger.Info("ollama chat request received",
		"remote_addr", r.RemoteAddr,
		"remote_ip", remoteIP,
		"user_agent", r.Header.Get("User-Agent"),
		"model", req.Model,
		"messages", len(req.Messages),
		"stream", req.Stream,
	)
	logger.Debug("ollama chat request details",
		"headers", headers,
		"body_len", len(rawBody),
	)

	// Sanitize: strip HA tools and instructions, extract area context
	areaContext := sanitizeHARequest(&req, logger)
	_ = areaContext // TODO: pass to agent for room-aware responses

	// Convert Ollama messages to agent messages
	messages := make([]agent.Message, len(req.Messages))
	for i, m := range req.Messages {
		images, err := parseOllamaImages(m.Images)
		if err != nil {
			ollamaError(w, http.StatusBadRequest, fmt.Sprintf("messages[%d]: %v", i, err))
			return
		}
		messages[i] = agent.Message{
			Role:    m.Role,
			Content: m.Content,
			Images:  images,
		}
	}

	// Detect Open WebUI auxiliary requests (title/tag generation) and
	// short-circuit them: route to a local model, skip tools, context
	// injection, and memory to avoid wasting resources on UI housekeeping.
	//
	// The "thane:local" profile applies a heavy scoring penalty (-200) to
	// paid models via HintLocalOnly, which effectively guarantees a local
	// model when any are available. If no local models are registered (e.g.
	// Ollama is down), the router will fall back to a paid model — this is
	// acceptable since the lightweight path still saves significant cost
	// by skipping context injection and tools.
	auxiliary := isOWUAuxiliaryRequest(messages)
	if auxiliary {
		logger.Info("auxiliary request detected, routing to local model without tools/context")
		req.Model = "thane:local"
	}

	// Map model name to routing profile.
	// Ollama "model" names like "thane:thinking" become routing hints.
	hints := map[string]string{
		"channel": "ollama",
	}
	if !auxiliary {
		hints["source"] = "owu"
	}
	model, hints, ocSystemPrompt := normalizeModelSelection(req.Model, hints, premiumQualityFloor(loop.Router()), logger)

	// Derive a conversation ID from the message history.
	// Open WebUI sends full history with each request, so hashing the first
	// user message gives a stable ID per chat. Different OWU chats get
	// isolated conversation buffers, archive sessions, and compaction state.
	//
	// Auxiliary requests use a fixed ID — they're ephemeral and should not
	// pollute the real conversation's memory or session tracking.
	conversationID := deriveConversationID(messages)
	if auxiliary {
		conversationID = "owu-auxiliary"
	}

	var channelBinding *memory.ChannelBinding
	if !auxiliary {
		channelBinding = (&memory.ChannelBinding{Channel: "owu"}).Normalize()
	}

	agentReq := &agent.Request{
		Messages:       messages,
		Model:          model,
		Hints:          hints,
		ConversationID: conversationID,
		ChannelBinding: channelBinding,
		SkipContext:    auxiliary,
		SystemPrompt:   ocSystemPrompt,
	}

	// Derive a short display name for the conversation's dashboard node.
	displayName := owuDisplayName(messages)

	// runFn dispatches through the OWU tracker when available, falling
	// back to calling the agent loop directly.
	runFn := func(ctx context.Context, req *agent.Request, cb agent.StreamCallback) (*agent.Response, error) {
		if tracker != nil {
			return tracker.Dispatch(ctx, req, cb, displayName)
		}
		return loop.Run(ctx, req, cb)
	}

	// Check if streaming was requested. For Ollama compatibility, a nil stream defaults to true.
	stream := true
	if req.Stream != nil {
		stream = *req.Stream
	}

	if stream {
		handleOllamaStreamingChatShared(w, r, agentReq, start, runFn, logger)
		return
	}

	// Non-streaming response
	resp, err := runFn(r.Context(), agentReq, nil)
	if err != nil {
		logger.Error("agent loop failed", "error", err)
		code, message := ollamaAgentError(err)
		ollamaError(w, code, message)
		return
	}

	duration := time.Since(start)

	ollamaResp := OllamaChatResponse{
		Model:     resp.Model,
		CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
		Message: OllamaChatMessage{
			Role:    "assistant",
			Content: resp.Content,
		},
		Done:          true,
		DoneReason:    "stop",
		TotalDuration: duration.Nanoseconds(),
		EvalDuration:  duration.Nanoseconds(),
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(ollamaResp); err != nil {
		logger.Debug("failed to encode ollama response", "error", err)
	}
}

// handleOllamaStreamingChatShared handles streaming chat responses in Ollama format.
//
// This function implements intelligent streaming that:
//   - Buffers initial tokens to detect tool calls
//   - Falls back to non-streaming mode if tool calls are detected
//   - Streams tokens in real-time for regular text responses
//   - Sends responses in Ollama's NDJSON streaming format
//
// The buffering approach prevents JSON tool call fragments from leaking into
// the stream while maintaining low latency for regular conversations.
//
// Parameters:
//   - w: HTTP response writer (must support flushing)
//   - r: HTTP request context
//   - req: Parsed agent request
//   - start: Request start time for duration tracking
//   - loop: Agent loop for processing
//   - logger: Logger for error tracking
//
// runFunc abstracts the agent dispatch so callers can route through
// the OWU tracker or call the agent loop directly.
type runFunc func(ctx context.Context, req *agent.Request, cb agent.StreamCallback) (*agent.Response, error)

func handleOllamaStreamingChatShared(w http.ResponseWriter, r *http.Request, req *agent.Request, start time.Time, run runFunc, logger *slog.Logger) {
	// Set headers for streaming. Omit "Connection: keep-alive" — it's a
	// hop-by-hop header forbidden in HTTP/2 (RFC 9113 §8.2.2).
	w.Header().Set("Content-Type", "application/x-ndjson")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("X-Accel-Buffering", "no")

	flusher, ok := w.(http.Flusher)
	if !ok {
		ollamaError(w, http.StatusInternalServerError, "streaming not supported")
		return
	}

	model := "thane"
	if req.Model != "" {
		model = req.Model
	}

	// Buffer to detect tool calls at the start of streaming
	// Note: Tool call detection relies on KindToolCallStart events which are emitted
	// by the agent loop before any tool-related tokens. This prevents JSON leakage
	// in the stream. If the LLM generates text-based tool calls (not using the proper
	// tool syntax), those would stream as regular text - but that's expected behavior.
	var buffer []agent.StreamEvent
	var hasToolCalls bool
	var streaming bool

	// Emit an immediate empty assistant chunk so downstream clients know
	// the stream is live even when first-token latency is high.
	initial := OllamaChatResponse{
		Model:     model,
		CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
		Message: OllamaChatMessage{
			Role:    "assistant",
			Content: "",
		},
		Done: false,
	}
	data, _ := json.Marshal(initial)
	fmt.Fprintf(w, "%s\n", data)
	flusher.Flush()

	// Create stream callback that buffers initially, then streams if no tool calls
	streamCallback := func(event agent.StreamEvent) {
		// If we've already detected tool calls, stop processing events
		if hasToolCalls {
			return
		}

		if !streaming {
			// Still buffering - check for tool calls
			if event.Kind == agent.KindToolCallStart {
				hasToolCalls = true
				buffer = nil // Clear buffer to avoid unnecessary memory usage
				// Stop buffering and fall back to non-streaming
				return
			}

			// Only buffer token events
			if event.Kind == agent.KindToken {
				buffer = append(buffer, event)
			}

			// Start streaming on the first text chunk so short or
			// coarse-grained upstream deltas don't get buffered until
			// completion. Tool-call paths still short-circuit above.
			if event.Kind == agent.KindToken && len(buffer) >= 1 && !hasToolCalls {
				streaming = true
				// Flush buffered content
				for _, bufferedEvent := range buffer {
					chunk := OllamaChatResponse{
						Model:     model,
						CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
						Message: OllamaChatMessage{
							Role:    "assistant",
							Content: bufferedEvent.Token,
						},
						Done: false,
					}
					data, _ := json.Marshal(chunk)
					fmt.Fprintf(w, "%s\n", data)
					flusher.Flush()
				}
				buffer = nil // Clear buffer
			}
		} else {
			// We're streaming - send tokens directly
			if event.Kind == agent.KindToken {
				chunk := OllamaChatResponse{
					Model:     model,
					CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
					Message: OllamaChatMessage{
						Role:    "assistant",
						Content: event.Token,
					},
					Done: false,
				}
				data, _ := json.Marshal(chunk)
				fmt.Fprintf(w, "%s\n", data)
				flusher.Flush()
			}
		}
	}

	// Run agent with streaming callback
	resp, err := run(r.Context(), req, streamCallback)
	if err != nil {
		logger.Error("streaming failed", "error", err)
		// Send error as final message
		errResp := OllamaChatResponse{
			Model:     model,
			CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
			Message: OllamaChatMessage{
				Role:    "assistant",
				Content: fmt.Sprintf("Error: %v", err),
			},
			Done:       true,
			DoneReason: "error",
		}
		data, _ := json.Marshal(errResp)
		fmt.Fprintf(w, "%s\n", data)
		flusher.Flush()
		return
	}

	// If we detected tool calls and buffered everything, send it now
	if hasToolCalls && !streaming {
		// Send the complete response as a single chunk
		chunk := OllamaChatResponse{
			Model:     model,
			CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
			Message: OllamaChatMessage{
				Role:    "assistant",
				Content: resp.Content,
			},
			Done: false,
		}
		data, _ := json.Marshal(chunk)
		fmt.Fprintf(w, "%s\n", data)
		flusher.Flush()
	}

	// If we never started streaming (very short response), send buffered tokens now
	if !streaming && !hasToolCalls && len(buffer) > 0 {
		for _, bufferedEvent := range buffer {
			chunk := OllamaChatResponse{
				Model:     model,
				CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
				Message: OllamaChatMessage{
					Role:    "assistant",
					Content: bufferedEvent.Token,
				},
				Done: false,
			}
			data, _ := json.Marshal(chunk)
			fmt.Fprintf(w, "%s\n", data)
			flusher.Flush()
		}
	}

	// Send final message with stats
	duration := time.Since(start)
	final := OllamaChatResponse{
		Model:     resp.Model,
		CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
		Message: OllamaChatMessage{
			Role:    "assistant",
			Content: "",
		},
		Done:          true,
		DoneReason:    "stop",
		TotalDuration: duration.Nanoseconds(),
		EvalDuration:  duration.Nanoseconds(),
	}
	data, _ = json.Marshal(final)
	fmt.Fprintf(w, "%s\n", data)
	flusher.Flush()
}

// handleOllamaTagsShared returns the list of available models.
// Returns intent-based routing profiles as Thane presents itself as a single
// model to Ollama clients, with actual model selection handled internally.
//
// The response format matches Ollama's /api/tags endpoint specification.
func handleOllamaTagsShared(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
	// Expose execution policies as Ollama "models". These entries describe how
	// Thane should route the orchestrator loop and any delegate sub-loops.
	now := time.Now().UTC().Format(time.RFC3339)
	baseDetails := OllamaModelDetail{
		Format:            "thane",
		Family:            "thane",
		Families:          []string{"thane"},
		ParameterSize:     "autonomous",
		QuantizationLevel: "native",
	}

	profiles := router.ExposedVirtualModels(router.VirtualModelRuntime{})

	var models []OllamaModel
	for _, p := range profiles {
		models = append(models, OllamaModel{
			Name:       p.Name,
			Model:      p.Name,
			ModifiedAt: now,
			Digest:     p.Description,
			Details:    baseDetails,
		})
	}

	resp := OllamaTagsResponse{Models: models}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(resp); err != nil {
		logger.Debug("failed to encode tags response", "error", err)
	}
}

// handleOllamaVersionShared returns the Ollama-compatible version.
// This reports Thane's version in Ollama's expected JSON format.
func handleOllamaVersionShared(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(OllamaVersionResponse{
		Version: "0.1.1", // Thane version
	}); err != nil {
		logger.Debug("failed to encode version response", "error", err)
	}
}

// ollamaError sends an error response in a format Ollama clients expect.
// Errors writing the response are intentionally not logged - if the client
// disconnected, there's nothing actionable we can do.
func ollamaError(w http.ResponseWriter, code int, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(code)
	// Best-effort write - client may have disconnected.
	body, err := json.Marshal(map[string]string{"error": message})
	if err != nil {
		body = []byte(`{"error":"internal error"}`)
	}
	_, _ = w.Write(body)
}

func ollamaAgentError(err error) (int, string) {
	return agentErrorDetails(err)
}

// sanitizeHARequest strips HA-provided tools and instructions, keeping only
// what Thane needs. HA is the dumb pipe; Thane is the brain.
//
// It modifies the request in-place by:
//   - Removing any tools array (Thane manages its own tools)
//   - Stripping HA system messages (Thane injects its own)
//   - Cleaning assistant messages that contain leaked JSON tool calls
//   - Extracting area context from system messages before removing them
//
// The areaContext return value contains any "You are in area X" information
// extracted from HA's system message, or an empty string if none was found.
func sanitizeHARequest(req *OllamaChatRequest, logger *slog.Logger) (areaContext string) {
	// Log and strip HA tools (we use our own)
	if len(req.Tools) > 0 {
		toolNames := make([]string, 0, len(req.Tools))
		for _, tool := range req.Tools {
			if fn, ok := tool["function"].(map[string]any); ok {
				if name, ok := fn["name"].(string); ok {
					toolNames = append(toolNames, name)
				}
			}
		}
		logger.Info("HA tools stripped",
			"count", len(req.Tools),
			"tools", toolNames,
		)
		req.Tools = nil
	}

	// Clean up assistant messages that have JSON tool calls as text
	// (from previous text-based tool call parsing that leaked to HA)
	// Check both unescaped and escaped JSON prefixes
	for i, msg := range req.Messages {
		if msg.Role == "assistant" {
			content := msg.Content
			// Check for JSON tool call patterns (escaped or unescaped)
			hasToolJSON := strings.HasPrefix(content, "{\"name\":") ||
				strings.HasPrefix(content, "{\\\"name\\\":") ||
				strings.Contains(content, "\"name\": \"find_entity\"") ||
				strings.Contains(content, "\"name\": \"call_service\"")

			if hasToolJSON {
				// Find where JSON objects end and keep only the trailing text
				cleaned := stripLeadingJSON(content)
				if cleaned != content {
					req.Messages[i].Content = cleaned
					logger.Info("stripped JSON from assistant message",
						"original_len", len(content),
						"cleaned", cleaned,
					)
				}
			}
		}
	}

	// Extract area context from system message before stripping
	// Look for "You are in area X (floor Y)"
	for i, msg := range req.Messages {
		if msg.Role == "system" {
			// Extract area context if present
			if idx := strings.Index(msg.Content, "You are in area "); idx != -1 {
				end := strings.Index(msg.Content[idx:], "\n")
				if end == -1 {
					end = len(msg.Content) - idx
				}
				areaContext = msg.Content[idx : idx+end]
				logger.Info("area context extracted", "area", areaContext)
			}

			// For now, strip the entire HA system message
			// Thane will inject its own via talents
			req.Messages = append(req.Messages[:i], req.Messages[i+1:]...)
			logger.Info("HA system message stripped")
			break
		}
	}

	return areaContext
}

// stripLeadingJSON removes JSON objects from the beginning of a string,
// returning any trailing text. It handles multiple consecutive JSON objects
// by repeatedly finding and removing complete JSON objects until no more
// are found or the string doesn't start with '{'.
//
// This is used to clean up assistant messages where JSON tool calls have
// leaked into the conversation history from previous interactions.
func stripLeadingJSON(s string) string {
	s = strings.TrimSpace(s)
	for strings.HasPrefix(s, "{") {
		end := findJSONEnd(s)
		if end <= 0 {
			break
		}
		s = strings.TrimSpace(s[end:])
	}
	return s
}

// findJSONEnd finds the end of a JSON object in a string by tracking
// brace depth while properly handling strings and escape sequences.
// Returns the index after the closing '}' of the first complete JSON object,
// or -1 if the string doesn't start with '{' or contains an incomplete object.
func findJSONEnd(s string) int {
	if !strings.HasPrefix(s, "{") {
		return -1
	}
	depth := 0
	inString := false
	escape := false
	for i, c := range s {
		if escape {
			escape = false
			continue
		}
		if c == '\\' {
			escape = true
			continue
		}
		if c == '"' {
			inString = !inString
			continue
		}
		if inString {
			continue
		}
		switch c {
		case '{':
			depth++
		case '}':
			depth--
			if depth == 0 {
				return i + 1
			}
		}
	}
	return -1
}

// Note: captureBody is defined in debug_request.go

// deriveConversationID creates a stable conversation ID from the message history.
// Uses the first user message as the key — all requests from the same Open WebUI
// chat will have the same first user message, giving a consistent ID.
// Falls back to "default" if no user messages are found.
func deriveConversationID(messages []agent.Message) string {
	for _, m := range messages {
		if m.Role == "user" && m.Content != "" {
			h := sha256.Sum256([]byte(m.Content))
			return "owu-" + hex.EncodeToString(h[:8]) // 16 hex chars, e.g. "owu-a1b2c3d4e5f6a7b8"
		}
	}
	return "default"
}

// owuDisplayName derives a short label for the dashboard node from the
// conversation's last user message. Returns at most 24 characters
// (including ellipsis when truncated). Truncation is rune-safe.
func owuDisplayName(messages []agent.Message) string {
	// Use the last user message — it's the most relevant for identifying
	// the conversation's current topic.
	for i := len(messages) - 1; i >= 0; i-- {
		if messages[i].Role == "user" && messages[i].Content != "" {
			s := messages[i].Content
			// Replace newlines with spaces for single-line label.
			s = strings.ReplaceAll(s, "\n", " ")

			const maxLen = 24
			runes := []rune(s)
			if len(runes) > maxLen {
				// Leave room for the ellipsis so total ≤ maxLen.
				s = string(runes[:maxLen-1]) + "…"
			}
			return s
		}
	}
	return "conversation"
}

// owuAuxiliaryPatterns are substrings found in Open WebUI's auxiliary prompts.
// OWU sends these as background requests for title generation, tag generation,
// and other UI housekeeping. The prompts vary across OWU versions and can be
// customized by users, so we match on characteristic phrases rather than exact
// strings. Case-insensitive matching is applied.
var owuAuxiliaryPatterns = []string{
	// Title generation variants
	"generate a concise, 3-5 word title",
	"generate a brief title for this chat",
	"create a concise title",
	"generate a title for this conversation",
	"provide a brief title",

	// Tag generation variants
	"generate tags for this chat",
	"suggest tags for this conversation",
	"generate 1-4 word tags",
	"provide tags for this chat",
}

// isOWUAuxiliaryRequest detects Open WebUI auxiliary/meta requests that should
// be handled by local models rather than the user's selected model. These are
// UI housekeeping tasks (title generation, tag generation) that happen
// invisibly in the background and don't need the full conversational pipeline.
func isOWUAuxiliaryRequest(messages []agent.Message) bool {
	// Check the last user message for known patterns.
	for i := len(messages) - 1; i >= 0; i-- {
		if messages[i].Role != "user" {
			continue
		}
		lower := strings.ToLower(messages[i].Content)
		for _, pattern := range owuAuxiliaryPatterns {
			if strings.Contains(lower, pattern) {
				return true
			}
		}
		return false // only check the most recent user message
	}
	return false
}
