package main

import (
	"bufio"
	"bytes"
	"compress/gzip"
	"context"
	"encoding/json"
	"fmt"
	"gf-lt/config"
	"gf-lt/models"
	"gf-lt/rag"
	"gf-lt/storage"
	"gf-lt/tools"
	"html"
	"io"
	"log/slog"
	"net"
	"net/http"
	"os"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/rivo/tview"
)

var (
	httpClient     = &http.Client{}
	cfg            *config.Config
	logger         *slog.Logger
	logLevel       = new(slog.LevelVar)
	ctx, cancel    = context.WithCancel(context.Background())
	activeChatName string
	chatRoundChan  = make(chan *models.ChatRoundReq, 1)
	chunkChan      = make(chan string, 10)
	openAIToolChan = make(chan string, 10)
	streamDone     = make(chan bool, 1)
	chatBody       *models.ChatBody
	store          storage.FullRepo
	defaultStarter = []models.RoleMsg{}
	interruptResp  atomic.Bool
	ragger         *rag.RAG
	chunkParser    ChunkParser
	lastToolCall   *models.FuncCall
	lastRespStats  *models.ResponseStats
	outputHandler  OutputHandler
	cliPrevOutput  string
	cliRespDone    chan bool
)

type OutputHandler interface {
	Write(p string)
	Writef(format string, args ...interface{})
	ScrollToEnd()
}

type TUIOutputHandler struct {
	tv *tview.TextView
}

func (h *TUIOutputHandler) Write(p string) {
	fmt.Fprint(h.tv, p)
}

func (h *TUIOutputHandler) Writef(format string, args ...interface{}) {
	fmt.Fprintf(h.tv, format, args...)
}

func (h *TUIOutputHandler) ScrollToEnd() {
	h.tv.ScrollToEnd()
}

type CLIOutputHandler struct{}

func (h *CLIOutputHandler) Write(p string) {
	fmt.Print(p)
}

func (h *CLIOutputHandler) Writef(format string, args ...interface{}) {
	fmt.Printf(format, args...)
}

func (h *CLIOutputHandler) ScrollToEnd() {
}

var (
	basicCard = &models.CharCard{
		ID:        models.ComputeCardID("assistant", "basic_sys"),
		SysPrompt: models.BasicSysMsg,
		FirstMsg:  models.DefaultFirstMsg,
		Role:      "assistant",
		FilePath:  "basic_sys",
	}
	sysMap               = map[string]*models.CharCard{}
	roleToID             = map[string]string{}
	modelHasVision       bool
	windowToolsAvailable bool
	// tooler               *tools.Tools
	//
	orator          Orator
	asr             STT
	localModelsMu   sync.RWMutex
	defaultLCPProps = map[string]float32{
		"temperature":    0.8,
		"dry_multiplier": 0.0,
		"min_p":          0.05,
		"n_predict":      -1.0,
	}
	ORFreeModels = []string{
		"google/gemini-2.0-flash-exp:free",
		"deepseek/deepseek-chat-v3-0324:free",
		"mistralai/mistral-small-3.2-24b-instruct:free",
		"qwen/qwen3-14b:free",
		"google/gemma-3-27b-it:free",
		"meta-llama/llama-3.3-70b-instruct:free",
	}
	LocalModels     = []string{}
	localModelsData *models.LCPModels
	orModelsData    *models.ORModels
)

// parseKnownToTag extracts known_to list from content using configured tag.
// Returns cleaned content and list of character names.
func parseKnownToTag(content string) []string {
	if cfg == nil || !cfg.CharSpecificContextEnabled {
		return nil
	}
	tag := cfg.CharSpecificContextTag
	if tag == "" {
		tag = "@"
	}
	// Pattern: tag + list + "@"
	pattern := regexp.QuoteMeta(tag) + `(.*?)@`
	re := regexp.MustCompile(pattern)
	matches := re.FindAllStringSubmatch(content, -1)
	if len(matches) == 0 {
		return nil
	}
	// There may be multiple tags; we combine all.
	var knownTo []string
	for _, match := range matches {
		if len(match) < 2 {
			continue
		}
		// Remove the entire matched tag from content
		list := strings.TrimSpace(match[1])
		if list == "" {
			continue
		}
		strings.SplitSeq(list, ",")
		// parts := strings.Split(list, ",")
		// for _, p := range parts {
		for p := range strings.SplitSeq(list, ",") {
			p = strings.TrimSpace(p)
			if p != "" {
				knownTo = append(knownTo, p)
			}
		}
	}
	// Also remove any leftover trailing "__" that might be orphaned? Not needed.
	return knownTo
}

// processMessageTag processes a message for known_to tag and sets KnownTo field.
// It also ensures the sender's role is included in KnownTo.
// If KnownTo already set (e.g., from DB), preserves it unless new tag found.
func processMessageTag(msg *models.RoleMsg) *models.RoleMsg {
	if cfg == nil || !cfg.CharSpecificContextEnabled {
		return msg
	}
	// If KnownTo already set, assume tag already processed (content cleaned).
	// However, we still check for new tags (maybe added later).
	knownTo := parseKnownToTag(msg.GetText())
	// If tag found, replace KnownTo with new list (merge with existing?)
	// For simplicity, if knownTo is not nil, replace.
	if knownTo == nil {
		return msg
	}
	msg.KnownTo = knownTo
	if msg.Role == "" {
		return msg
	}
	if !slices.Contains(msg.KnownTo, msg.Role) {
		msg.KnownTo = append(msg.KnownTo, msg.Role)
	}
	return msg
}

// filterMessagesForCharacter returns messages visible to the specified character.
// If CharSpecificContextEnabled is false, returns all messages.
func filterMessagesForCharacter(messages []models.RoleMsg, character string) []models.RoleMsg {
	if strings.Contains(cfg.CurrentAPI, "chat") {
		return messages
	}
	if cfg == nil || !cfg.CharSpecificContextEnabled || character == "" {
		return messages
	}
	if character == "system" { // system sees every message
		return messages
	}
	filtered := make([]models.RoleMsg, 0, len(messages))
	for i := range messages {
		// If KnownTo is nil or empty, message is visible to all
		// system msg cannot be filtered
		if len(messages[i].KnownTo) == 0 || messages[i].Role == "system" {
			filtered = append(filtered, messages[i])
			continue
		}
		if slices.Contains(messages[i].KnownTo, character) {
			// Check if character is in KnownTo lis
			filtered = append(filtered, messages[i])
		}
	}
	return filtered
}

func consolidateAssistantMessages(messages []models.RoleMsg) []models.RoleMsg {
	if len(messages) == 0 {
		return messages
	}
	result := make([]models.RoleMsg, 0, len(messages))
	for i := range messages {
		// Non-assistant messages are appended as-is
		if messages[i].Role != cfg.AssistantRole {
			result = append(result, messages[i])
			continue
		}
		// Assistant message: start a new block or merge with the last one
		if len(result) == 0 || result[len(result)-1].Role != cfg.AssistantRole {
			// First assistant in a block: append a copy (avoid mutating input)
			result = append(result, messages[i].Copy())
			continue
		}
		// Merge with the last assistant message
		last := &result[len(result)-1]
		// If either message has structured content, unify to ContentParts
		if last.IsContentParts() || messages[i].IsContentParts() {
			// Convert last to ContentParts if needed, preserving ToolCallID
			if !last.IsContentParts() {
				toolCallID := last.ToolCallID
				*last = models.NewMultimodalMsg(last.Role, []interface{}{
					models.TextContentPart{Type: "text", Text: last.Content},
				})
				last.ToolCallID = toolCallID
			}
			// Add current message's content to last
			if messages[i].IsContentParts() {
				last.ContentParts = append(last.ContentParts, messages[i].GetContentParts()...)
			} else if messages[i].Content != "" {
				last.AddTextPart(messages[i].Content)
			}
		} else {
			// Both simple strings: concatenate with newline
			if last.Content != "" && messages[i].Content != "" {
				last.Content += "\n" + messages[i].Content
			} else if messages[i].Content != "" {
				last.Content = messages[i].Content
			}
			// ToolCallID is already preserved in last
		}
	}
	return result
}

// GetLogLevel returns the current log level as a string
func GetLogLevel() string {
	level := logLevel.Level()
	switch level {
	case slog.LevelDebug:
		return "Debug"
	case slog.LevelInfo:
		return "Info"
	case slog.LevelWarn:
		return "Warn"
	default:
		// For any other values, return "Info" as default
		return "Info"
	}
}

func createClient(connectTimeout time.Duration) *http.Client {
	// Custom transport with connection timeout
	transport := &http.Transport{
		DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
			// Create a dialer with connection timeout
			dialer := &net.Dialer{
				Timeout:   connectTimeout,
				KeepAlive: 30 * time.Second, // Optional
			}
			return dialer.DialContext(ctx, network, addr)
		},
		// Other transport settings (optional)
		TLSHandshakeTimeout:   connectTimeout,
		ResponseHeaderTimeout: connectTimeout,
	}
	// Client with no overall timeout (or set to streaming-safe duration)
	return &http.Client{
		Transport: transport,
		Timeout:   0, // No overall timeout (for streaming)
	}
}

func warmUpModel() {
	if !isLocalLlamacpp() {
		return
	}
	// Check if model is already loaded
	loaded, err := isModelLoaded(chatBody.Model)
	if err != nil {
		logger.Debug("failed to check model status", "model", chatBody.Model, "error", err)
		// Continue with warmup attempt anyway
	}
	if loaded {
		showToast("model already loaded", "Model "+chatBody.Model+" is already loaded.")
		return
	}
	go func() {
		var data []byte
		var err error
		switch {
		case strings.HasSuffix(cfg.CurrentAPI, "/completion"):
			// Old completion endpoint
			req := models.NewLCPReq(".", chatBody.Model, nil, map[string]float32{
				"temperature":    0.8,
				"dry_multiplier": 0.0,
				"min_p":          0.05,
				"n_predict":      0,
			}, []string{})
			req.Stream = false
			data, err = json.Marshal(req)
		case strings.Contains(cfg.CurrentAPI, "/v1/chat/completions"):
			// OpenAI-compatible chat endpoint
			req := models.OpenAIReq{
				ChatBody: &models.ChatBody{
					Model: chatBody.Model,
					Messages: []models.RoleMsg{
						{Role: "system", Content: "."},
					},
					Stream: false,
				},
				Tools: nil,
			}
			data, err = json.Marshal(req)
		default:
			// Unknown local endpoint, skip
			return
		}
		if err != nil {
			logger.Debug("failed to marshal warmup request", "error", err)
			return
		}
		resp, err := httpClient.Post(cfg.CurrentAPI, "application/json", bytes.NewReader(data))
		if err != nil {
			logger.Debug("warmup request failed", "error", err)
			return
		}
		resp.Body.Close()
		// Start monitoring for model load completion
		monitorModelLoad(chatBody.Model)
	}()
}

// nolint
func fetchDSBalance() *models.DSBalance {
	url := "https://api.deepseek.com/user/balance"
	method := "GET"
	// nolint
	req, err := http.NewRequest(method, url, nil)
	if err != nil {
		logger.Warn("failed to create request", "error", err)
		return nil
	}
	req.Header.Add("Accept", "application/json")
	req.Header.Add("Authorization", "Bearer "+cfg.DeepSeekToken)
	res, err := httpClient.Do(req)
	if err != nil {
		logger.Warn("failed to make request", "error", err)
		return nil
	}
	defer res.Body.Close()
	resp := models.DSBalance{}
	if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
		return nil
	}
	return &resp
}

func fetchORModels(free bool) ([]string, error) {
	resp, err := http.Get("https://openrouter.ai/api/v1/models")
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status)
		return nil, err
	}
	data := &models.ORModels{}
	if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
		return nil, err
	}
	orModelsData = data
	freeModels := data.ListModels(free)
	return freeModels, nil
}

func fetchLCPModels() ([]string, error) {
	resp, err := http.Get(cfg.FetchModelNameAPI)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		err := fmt.Errorf("failed to fetch or models; status: %s", resp.Status)
		return nil, err
	}
	data := &models.LCPModels{}
	if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
		return nil, err
	}
	localModels := data.ListModels()
	return localModels, nil
}

// fetchLCPModelsWithLoadStatus returns models with "(loaded)" indicator for loaded models
func fetchLCPModelsWithLoadStatus() ([]string, error) {
	modelList, err := fetchLCPModelsWithStatus()
	if err != nil {
		return nil, err
	}
	result := make([]string, 0, len(modelList.Data))
	li := 0 // loaded index
	for i, m := range modelList.Data {
		modelName := m.ID
		if m.Status.Value == "loaded" {
			modelName = models.LoadedMark + modelName
			li = i
		}
		result = append(result, modelName)
	}
	if li == 0 {
		return result, nil // no loaded modelList
	}
	loadedModel := result[li]
	result = append(result[:li], result[li+1:]...)
	return slices.Concat([]string{loadedModel}, result), nil
}

// fetchLCPModelsWithStatus returns the full LCPModels struct including status information.
func fetchLCPModelsWithStatus() (*models.LCPModels, error) {
	resp, err := http.Get(cfg.FetchModelNameAPI)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		err := fmt.Errorf("failed to fetch llama.cpp models; status: %s", resp.Status)
		return nil, err
	}
	data := &models.LCPModels{}
	if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
		return nil, err
	}
	localModelsData = data
	return data, nil
}

// isModelLoaded checks if the given model ID is currently loaded in llama.cpp server.
func isModelLoaded(modelID string) (bool, error) {
	models, err := fetchLCPModelsWithStatus()
	if err != nil {
		return false, err
	}
	for _, m := range models.Data {
		if m.ID == modelID {
			return m.Status.Value == "loaded", nil
		}
	}
	return false, nil
}

func ModelHasVision(api, modelID string) bool {
	switch {
	case strings.Contains(api, "deepseek"):
		return false
	case strings.Contains(api, "openrouter"):
		resp, err := http.Get("https://openrouter.ai/api/v1/models")
		if err != nil {
			logger.Warn("failed to fetch OR models for vision check", "error", err)
			return false
		}
		defer resp.Body.Close()
		orm := &models.ORModels{}
		if err := json.NewDecoder(resp.Body).Decode(orm); err != nil {
			logger.Warn("failed to decode OR models for vision check", "error", err)
			return false
		}
		return orm.HasVision(modelID)
	default:
		models, err := fetchLCPModelsWithStatus()
		if err != nil {
			logger.Warn("failed to fetch LCP models for vision check", "error", err)
			return false
		}
		return models.HasVision(modelID)
	}
}

func UpdateToolCapabilities() {
	if !cfg.ToolUse {
		return
	}
	modelHasVision = false
	if cfg == nil || cfg.CurrentAPI == "" {
		logger.Warn("cannot determine model capabilities: cfg or CurrentAPI is nil")
		// tooler.RegisterWindowTools(modelHasVision)
		return
	}
	prevHasVision := modelHasVision
	modelHasVision = ModelHasVision(cfg.CurrentAPI, cfg.CurrentModel)
	if modelHasVision {
		logger.Info("model has vision support", "model", cfg.CurrentModel, "api", cfg.CurrentAPI)
	} else {
		logger.Info("model does not have vision support", "model", cfg.CurrentModel, "api", cfg.CurrentAPI)
		if windowToolsAvailable && !prevHasVision && !modelHasVision {
			showToast("window tools", "Window capture-and-view unavailable: model lacks vision support")
		}
	}
	// tooler.RegisterWindowTools(modelHasVision)
}

// monitorModelLoad starts a goroutine that periodically checks if the specified model is loaded.
func monitorModelLoad(modelID string) {
	go func() {
		timeout := time.After(2 * time.Minute) // max wait 2 minutes
		ticker := time.NewTicker(2 * time.Second)
		defer ticker.Stop()
		for {
			select {
			case <-timeout:
				logger.Debug("model load monitoring timeout", "model", modelID)
				return
			case <-ticker.C:
				loaded, err := isModelLoaded(modelID)
				if err != nil {
					logger.Debug("failed to check model status", "model", modelID, "error", err)
					continue
				}
				if loaded {
					showToast("model loaded", "Model "+modelID+" is now loaded and ready.")
					refreshChatDisplay()
					return
				}
			}
		}
	}()
}

// extractDetailedErrorFromBytes extracts detailed error information from response body bytes
func extractDetailedErrorFromBytes(body []byte, statusCode int) string {
	// Try to decompress gzip if the response is compressed
	if len(body) >= 2 && body[0] == 0x1f && body[1] == 0x8b {
		reader, err := gzip.NewReader(bytes.NewReader(body))
		if err == nil {
			decompressed, err := io.ReadAll(reader)
			reader.Close()
			if err == nil {
				body = decompressed
			}
		}
	}
	// Try to parse as JSON to extract detailed error information
	var errorResponse map[string]any
	if err := json.Unmarshal(body, &errorResponse); err == nil {
		// Check if it's an error response with detailed information
		if errorData, ok := errorResponse["error"]; ok {
			if errorMap, ok := errorData.(map[string]any); ok {
				var errorMsg string
				if msg, ok := errorMap["message"]; ok {
					errorMsg = fmt.Sprintf("%v", msg)
				}
				var details []string
				if code, ok := errorMap["code"]; ok {
					details = append(details, fmt.Sprintf("Code: %v", code))
				}
				if metadata, ok := errorMap["metadata"]; ok {
					// Handle metadata which might contain raw error details
					if metadataMap, ok := metadata.(map[string]any); ok {
						if raw, ok := metadataMap["raw"]; ok {
							// Parse the raw error string if it's JSON
							var rawError map[string]any
							if rawStr, ok := raw.(string); ok && json.Unmarshal([]byte(rawStr), &rawError) == nil {
								if rawErrorData, ok := rawError["error"]; ok {
									if rawErrorMap, ok := rawErrorData.(map[string]any); ok {
										if rawMsg, ok := rawErrorMap["message"]; ok {
											return fmt.Sprintf("API Error: %s", rawMsg)
										}
									}
								}
							}
						}
					}
					details = append(details, fmt.Sprintf("Metadata: %v", metadata))
				}
				if len(details) > 0 {
					return fmt.Sprintf("API Error: %s (%s)", errorMsg, strings.Join(details, ", "))
				}
				return "API Error: " + errorMsg
			}
		}
	}
	// If not a structured error response, return the raw body with status
	return fmt.Sprintf("HTTP Status: %d, Response Body: %s", statusCode, string(body))
}

func finalizeRespStats(tokenCount int, startTime time.Time) {
	duration := time.Since(startTime).Seconds()
	var tps float64
	if duration > 0 {
		tps = float64(tokenCount) / duration
	}
	lastRespStats = &models.ResponseStats{
		Tokens:       tokenCount,
		Duration:     duration,
		TokensPerSec: tps,
	}
}

func dumpRequestToFile(api string, body []byte, token string, statusCode int, respError string) {
	dumpDir := "dumps"
	if err := os.MkdirAll(dumpDir, 0755); err != nil {
		logger.Warn("failed to create dumps directory", "error", err)
		return
	}

	timestamp := time.Now().Format("20060102_150405")
	bodyFilename := fmt.Sprintf("%s/request_%s_%d_body.json", dumpDir, timestamp, statusCode)
	curlFilename := fmt.Sprintf("%s/request_%s_%d.curl", dumpDir, timestamp, statusCode)

	if err := os.WriteFile(bodyFilename, body, 0644); err != nil {
		logger.Warn("failed to write request body dump", "error", err, "filename", bodyFilename)
		return
	}

	var authPart string
	if token != "" {
		authPart = fmt.Sprintf(`-H "Authorization: Bearer %s"`, token)
	}

	curlCmd := fmt.Sprintf(`curl -X POST "%s" \
  -H "Content-Type: application/json" \
  %s \
  --data-binary @%s`,
		api, authPart, bodyFilename)

	if err := os.WriteFile(curlFilename, []byte(curlCmd), 0644); err != nil {
		logger.Warn("failed to write request dump", "error", err, "filename", curlFilename)
		return
	}

	logger.Info("request dump saved", "curl_file", curlFilename, "body_file", bodyFilename, "status", statusCode)
}

// sendMsgToLLM expects streaming resp
func sendMsgToLLM(body io.Reader) {
	choseChunkParser()
	// openrouter does not respect stop strings, so we have to cut the message ourselves
	stopStrings := chatBody.MakeStopSliceExcluding("", listChatRoles())

	// Read body content for potential dump on error
	bodyBytes, err := io.ReadAll(body)
	if err != nil {
		logger.Error("failed to read request body", "error", err)
		showToast("error", "apicall failed:"+err.Error())
		streamDone <- true
		return
	}

	req, err := http.NewRequest("POST", cfg.CurrentAPI, bytes.NewReader(bodyBytes))
	if err != nil {
		logger.Error("newreq error", "error", err)
		showToast("error", "apicall failed:"+err.Error())
		streamDone <- true
		return
	}
	req.Header.Add("Accept", "application/json")
	req.Header.Add("Content-Type", "application/json")
	req.Header.Add("Authorization", "Bearer "+chunkParser.GetToken())
	req.Header.Set("Accept-Encoding", "gzip")
	// nolint
	resp, err := httpClient.Do(req)
	if err != nil {
		logger.Error("llamacpp api", "error", err)
		showToast("error", "apicall failed:"+err.Error())
		streamDone <- true
		return
	}
	// Check if the initial response is an error before starting to stream
	if resp.StatusCode >= 400 {
		// Read the response body to get detailed error information
		respBodyBytes, err := io.ReadAll(resp.Body)
		if err != nil {
			logger.Error("failed to read error response body", "error", err, "status_code", resp.StatusCode)
			detailedError := fmt.Sprintf("HTTP Status: %d, Failed to read response body: %v", resp.StatusCode, err)
			showToast("API Error", detailedError)
			resp.Body.Close()
			streamDone <- true
			return
		}
		// Parse the error response for detailed information
		detailedError := extractDetailedErrorFromBytes(respBodyBytes, resp.StatusCode)
		logger.Error("API returned error status", "status_code", resp.StatusCode, "detailed_error", detailedError)
		dumpRequestToFile(cfg.CurrentAPI, bodyBytes, chunkParser.GetToken(), resp.StatusCode, detailedError)
		showToast("API Error", detailedError)
		resp.Body.Close()
		streamDone <- true
		return
	}
	//
	defer resp.Body.Close()
	reader := bufio.NewReader(resp.Body)
	counter := uint32(0)
	tokenCount := 0
	startTime := time.Now()
	hasReasoning := false
	reasoningSent := false
	defer func() {
		finalizeRespStats(tokenCount, startTime)
	}()
	for {
		var (
			answerText string
			chunk      *models.TextChunk
		)
		counter++
		// to stop from spiriling in infinity read of bad bytes that happens with poor connection
		if cfg.ChunkLimit > 0 && counter > cfg.ChunkLimit {
			logger.Warn("response hit chunk limit", "limit", cfg.ChunkLimit)
			streamDone <- true
			break
		}
		line, err := reader.ReadBytes('\n')
		if err != nil {
			// Check if this is an EOF error and if the response contains detailed error information
			if err == io.EOF {
				// For streaming responses, we may have already consumed the error body
				// So we'll use the original status code to provide context
				detailedError := fmt.Sprintf("Streaming connection closed unexpectedly (Status: %d). This may indicate an API error. Check your API provider and model settings.", resp.StatusCode)
				logger.Error("error reading response body", "error", err, "detailed_error", detailedError,
					"status_code", resp.StatusCode, "user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI)
				showToast("API Error", detailedError)
			} else {
				logger.Error("error reading response body", "error", err, "line", string(line),
					"user_role", cfg.UserRole, "parser", chunkParser, "link", cfg.CurrentAPI)
				// if err.Error() != "EOF" {
				showToast("API error", err.Error())
			}
			streamDone <- true
			break
			// }
			// continue
		}
		if len(line) <= 1 {
			if interruptResp.Load() {
				goto interrupt // get unstuck from bad connection
			}
			continue // skip \n
		}
		// starts with -> data:
		line = line[6:]
		logger.Debug("debugging resp", "line", string(line))
		if bytes.Equal(line, []byte("[DONE]\n")) {
			streamDone <- true
			break
		}
		if bytes.Equal(line, []byte("ROUTER PROCESSING\n")) {
			continue
		}
		chunk, err = chunkParser.ParseChunk(line)
		if err != nil {
			logger.Error("error parsing response body", "error", err,
				"line", string(line), "url", cfg.CurrentAPI)
			showToast("LLM Response Error", "Failed to parse LLM response: "+err.Error())
			streamDone <- true
			break
		}
		// // problem: this catches any mention of the word 'error'
		// Handle error messages in response content
		// example needed, since llm could use the word error in the normal msg
		// if string(line) != "" && strings.Contains(strings.ToLower(string(line)), "error") {
		// 	logger.Error("API error response detected", "line", line, "url", cfg.CurrentAPI)
		// 	streamDone <- true
		// 	break
		// }
		if chunk.Finished {
			// Close the thinking block if we were streaming reasoning and haven't closed it yet
			if hasReasoning && !reasoningSent {
				chunkChan <- "</think>"
				tokenCount++
			}
			if chunk.Chunk != "" {
				logger.Warn("text inside of finish llmchunk", "chunk", chunk, "counter", counter)
				answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
				chunkChan <- answerText
				tokenCount++
			}
			streamDone <- true
			break
		}
		if counter == 0 {
			chunk.Chunk = strings.TrimPrefix(chunk.Chunk, " ")
		}
		// Handle reasoning chunks - stream them immediately as they arrive
		if chunk.Reasoning != "" && !reasoningSent {
			if !hasReasoning {
				// First reasoning chunk - send opening tag
				chunkChan <- "<think>"
				tokenCount++
				hasReasoning = true
			}
			// Stream reasoning content immediately
			answerText = strings.ReplaceAll(chunk.Reasoning, "\n\n", "\n")
			if answerText != "" {
				chunkChan <- answerText
				tokenCount++
			}
		}
		// When we get content and have been streaming reasoning, close the thinking block
		if chunk.Chunk != "" && hasReasoning && !reasoningSent {
			// Close the thinking block before sending actual content
			chunkChan <- "</think>"
			tokenCount++
			reasoningSent = true
		}
		// bot sends way too many \n
		answerText = strings.ReplaceAll(chunk.Chunk, "\n\n", "\n")
		// Accumulate text to check for stop strings that might span across chunks
		// check if chunk is in stopstrings => stop
		// this check is needed only for openrouter /v1/completion, since it does not respect stop slice
		if chunkParser.GetAPIType() == models.APITypeCompletion &&
			slices.Contains(stopStrings, answerText) {
			logger.Debug("stop string detected on client side for completion endpoint", "stop_string", answerText)
			streamDone <- true
			break
		}
		if answerText != "" {
			chunkChan <- answerText
			tokenCount++
		}
		openAIToolChan <- chunk.ToolChunk
		if chunk.FuncName != "" {
			lastToolCall.Name = chunk.FuncName
			// Store the tool call ID for the response
			lastToolCall.ID = chunk.ToolID
		}
	interrupt:
		if interruptResp.Load() { // read bytes, so it would not get into beginning of the next req
			logger.Info("interrupted bot response", "chunk_counter", counter)
			streamDone <- true
			break
		}
	}
}

func roleToIcon(role string) string {
	return "<" + role + ">: "
}

func chatWatcher(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case chatRoundReq := <-chatRoundChan:
			if err := chatRound(chatRoundReq); err != nil {
				logger.Error("failed to chatRound", "err", err)
			}
		}
	}
}

// inpired by https://github.com/rivo/tview/issues/225
func showSpinner() {
	if cfg.CLIMode {
		showSpinnerCLI()
		return
	}
	spinners := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
	var i int
	botPersona := cfg.AssistantRole
	if cfg.WriteNextMsgAsCompletionAgent != "" {
		botPersona = cfg.WriteNextMsgAsCompletionAgent
	}
	for botRespMode.Load() || toolRunningMode.Load() {
		time.Sleep(400 * time.Millisecond)
		spin := i % len(spinners)
		app.QueueUpdateDraw(func() {
			switch {
			case toolRunningMode.Load():
				textArea.SetTitle(spinners[spin] + " tool")
			case botRespMode.Load():
				textArea.SetTitle(spinners[spin] + " " + botPersona + " (F6 to interrupt)")
			default:
				textArea.SetTitle(spinners[spin] + " input")
			}
		})
		i++
	}
	app.QueueUpdateDraw(func() {
		textArea.SetTitle("input")
	})
}

func showSpinnerCLI() {
	for botRespMode.Load() || toolRunningMode.Load() {
		time.Sleep(400 * time.Millisecond)
	}
}

func chatRound(r *models.ChatRoundReq) error {
	interruptResp.Store(false)
	botRespMode.Store(true)
	go showSpinner()
	updateStatusLine()
	botPersona := cfg.AssistantRole
	if cfg.WriteNextMsgAsCompletionAgent != "" {
		botPersona = cfg.WriteNextMsgAsCompletionAgent
	}
	defer func() {
		botRespMode.Store(false)
		ClearImageAttachment()
	}()
	// check that there is a model set to use if is not local
	choseChunkParser()
	reader, err := chunkParser.FormMsg(r.UserMsg, r.Role, r.Resume)
	if reader == nil || err != nil {
		logger.Error("empty reader from msgs", "role", r.Role, "error", err)
		return err
	}
	if cfg.SkipLLMResp {
		return nil
	}
	go sendMsgToLLM(reader)
	logger.Debug("looking at vars in chatRound", "msg", r.UserMsg, "regen", r.Regen, "resume", r.Resume)
	msgIdx := len(chatBody.Messages)
	if !r.Resume {
		// Add empty message to chatBody immediately so it persists during Alt+T toggle
		chatBody.Messages = append(chatBody.Messages, models.RoleMsg{
			Role: botPersona, Content: "",
		})
		nl := "\n\n"
		prevText := cliPrevOutput
		if cfg.CLIMode {
			if strings.HasSuffix(prevText, nl) {
				nl = ""
			} else if strings.HasSuffix(prevText, "\n") {
				nl = "\n"
			}
		} else {
			prevText = textView.GetText(true)
			if strings.HasSuffix(prevText, nl) {
				nl = ""
			} else if strings.HasSuffix(prevText, "\n") {
				nl = "\n"
			}
		}
		outputHandler.Writef("%s[-:-:b](%d) %s[-:-:-]\n", nl, msgIdx, roleToIcon(botPersona))
	} else {
		msgIdx = len(chatBody.Messages) - 1
	}
	respText := strings.Builder{}
	toolResp := strings.Builder{}
	// Variables for handling thinking blocks during streaming
	inThinkingBlock := false
	thinkingBuffer := strings.Builder{}
	justExitedThinkingCollapsed := false
out:
	for {
		select {
		case chunk := <-chunkChan:
			// Handle thinking blocks during streaming
			if strings.HasPrefix(chunk, "<think>") && !inThinkingBlock {
				// Start of thinking block
				inThinkingBlock = true
				thinkingBuffer.Reset()
				thinkingBuffer.WriteString(chunk)
				if thinkingCollapsed {
					// Show placeholder immediately when thinking starts in collapsed mode
					outputHandler.Write("[yellow::i][thinking... (press Alt+T to expand)][-:-:-]")
					if cfg.AutoScrollEnabled {
						outputHandler.ScrollToEnd()
					}
					respText.WriteString(chunk)
					continue
				}
			} else if inThinkingBlock {
				thinkingBuffer.WriteString(chunk)
				if strings.Contains(chunk, "</think>") {
					// End of thinking block
					inThinkingBlock = false
					if thinkingCollapsed {
						// Thinking already displayed as placeholder, just update respText
						respText.WriteString(chunk)
						justExitedThinkingCollapsed = true
						if cfg.AutoScrollEnabled {
							outputHandler.ScrollToEnd()
						}
						continue
					}
					// If not collapsed, fall through to normal display
				} else if thinkingCollapsed {
					// Still in thinking block and collapsed - just buffer, don't display
					respText.WriteString(chunk)
					continue
				}
				// If not collapsed, fall through to normal display
			}
			// Add spacing after collapsed thinking block before real response
			if justExitedThinkingCollapsed {
				chunk = "\n\n" + chunk
				justExitedThinkingCollapsed = false
			}
			outputHandler.Write(chunk)
			respText.WriteString(chunk)
			// Update the message in chatBody.Messages so it persists during Alt+T
			if !r.Resume {
				chatBody.Messages[msgIdx].Content += respText.String()
			}
			if cfg.AutoScrollEnabled {
				outputHandler.ScrollToEnd()
			}
			// Send chunk to audio stream handler
			if cfg.TTS_ENABLED {
				TTSTextChan <- chunk
			}
		case toolChunk := <-openAIToolChan:
			outputHandler.Write(toolChunk)
			toolResp.WriteString(toolChunk)
			if cfg.AutoScrollEnabled {
				outputHandler.ScrollToEnd()
			}
		case <-streamDone:
			for len(chunkChan) > 0 {
				chunk := <-chunkChan
				outputHandler.Write(chunk)
				respText.WriteString(chunk)
				if cfg.AutoScrollEnabled {
					outputHandler.ScrollToEnd()
				}
				if cfg.TTS_ENABLED {
					TTSTextChan <- chunk
				}
			}
			if cfg.TTS_ENABLED {
				TTSFlushChan <- true
			}
			break out
		}
	}
	var msgStats *models.ResponseStats
	if lastRespStats != nil {
		msgStats = &models.ResponseStats{
			Tokens:       lastRespStats.Tokens,
			Duration:     lastRespStats.Duration,
			TokensPerSec: lastRespStats.TokensPerSec,
		}
		lastRespStats = nil
	}
	botRespMode.Store(false)
	if r.Resume {
		chatBody.Messages[len(chatBody.Messages)-1].Content += respText.String()
		updatedMsg := chatBody.Messages[len(chatBody.Messages)-1]
		processedMsg := processMessageTag(&updatedMsg)
		chatBody.Messages[len(chatBody.Messages)-1] = *processedMsg
		if msgStats != nil && chatBody.Messages[len(chatBody.Messages)-1].Role != cfg.ToolRole {
			chatBody.Messages[len(chatBody.Messages)-1].Stats = msgStats
		}
	} else {
		chatBody.Messages[msgIdx].Content = respText.String()
		processedMsg := processMessageTag(&chatBody.Messages[msgIdx])
		chatBody.Messages[msgIdx] = *processedMsg
		if msgStats != nil && chatBody.Messages[msgIdx].Role != cfg.ToolRole {
			chatBody.Messages[msgIdx].Stats = msgStats
		}
		stopTTSIfNotForUser(&chatBody.Messages[msgIdx])
	}
	cleanChatBody()
	refreshChatDisplay()
	updateStatusLine()
	// bot msg is done; now check it for func call
	// logChat(activeChatName, chatBody.Messages)
	if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil {
		logger.Warn("failed to update storage", "error", err, "name", activeChatName)
	}
	// Strip think blocks before parsing for tool calls
	respTextNoThink := models.ThinkRE.ReplaceAllString(respText.String(), "")
	if interruptResp.Load() {
		return nil
	}
	if findCall(respTextNoThink, toolResp.String()) {
		// Tool was found and executed, subsequent chatRound will signal cliRespDone when complete
		return nil
	}
	// No tool call - signal completion now
	if cfg.CLIMode && cliRespDone != nil {
		select {
		case cliRespDone <- true:
		default:
		}
	}
	// Check if this message was sent privately to specific characters
	// If so, trigger those characters to respond if that char is not controlled by user
	// perhaps we should have narrator role to determine which char is next to act
	if cfg.AutoTurn {
		lastMsg := chatBody.Messages[len(chatBody.Messages)-1]
		if len(lastMsg.KnownTo) > 0 {
			triggerPrivateMessageResponses(&lastMsg)
		}
	}
	return nil
}

// cleanChatBody removes messages with null or empty content to prevent API issues
func cleanChatBody() {
	if chatBody == nil || chatBody.Messages == nil {
		return
	}
	// Tool request cleaning is now configurable via AutoCleanToolCallsFromCtx (default false)
	// /completion msg where part meant for user and other part tool call
	// chatBody.Messages = cleanToolCalls(chatBody.Messages)
	chatBody.Messages = consolidateAssistantMessages(chatBody.Messages)
}

// convertJSONToMapStringString unmarshals JSON into map[string]interface{} and converts all values to strings.
func convertJSONToMapStringString(jsonStr string) (map[string]string, error) {
	// Extract JSON object from string - models may output extra text after JSON
	jsonStr = extractJSON(jsonStr)
	var raw map[string]interface{}
	if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
		return nil, err
	}
	result := make(map[string]string, len(raw))
	for k, v := range raw {
		switch val := v.(type) {
		case string:
			result[k] = val
		case float64:
			result[k] = strconv.FormatFloat(val, 'f', -1, 64)
		case int, int64, int32:
			// json.Unmarshal converts numbers to float64, but handle other integer types if they appear
			result[k] = fmt.Sprintf("%v", val)
		case bool:
			result[k] = strconv.FormatBool(val)
		case nil:
			result[k] = ""
		default:
			result[k] = fmt.Sprintf("%v", val)
		}
	}
	return result, nil
}

// extractJSON finds the first { and last } to extract only the JSON object
// This handles cases where models output extra text after JSON
func extractJSON(s string) string {
	// Try direct parse first - if it works, return as-is
	var dummy map[string]interface{}
	if err := json.Unmarshal([]byte(s), &dummy); err == nil {
		return s
	}
	// Otherwise find JSON boundaries
	start := strings.Index(s, "{")
	end := strings.LastIndex(s, "}")
	if start >= 0 && end > start {
		return s[start : end+1]
	}
	return s
}

// unmarshalFuncCall unmarshals a JSON tool call, converting numeric arguments to strings.
func unmarshalFuncCall(jsonStr string) (*models.FuncCall, error) {
	type tempFuncCall struct {
		ID   string                 `json:"id,omitempty"`
		Name string                 `json:"name"`
		Args map[string]interface{} `json:"args"`
	}
	var temp tempFuncCall
	if err := json.Unmarshal([]byte(jsonStr), &temp); err != nil {
		return nil, err
	}
	fc := &models.FuncCall{
		ID:   temp.ID,
		Name: temp.Name,
		Args: make(map[string]string, len(temp.Args)),
	}
	for k, v := range temp.Args {
		switch val := v.(type) {
		case string:
			fc.Args[k] = val
		case float64:
			fc.Args[k] = strconv.FormatFloat(val, 'f', -1, 64)
		case int, int64, int32:
			fc.Args[k] = fmt.Sprintf("%v", val)
		case bool:
			fc.Args[k] = strconv.FormatBool(val)
		case nil:
			fc.Args[k] = ""
		default:
			fc.Args[k] = fmt.Sprintf("%v", val)
		}
	}
	return fc, nil
}

// findCall: adds chatRoundReq into the chatRoundChan and returns true if does
func findCall(msg, toolCall string) bool {
	var fc *models.FuncCall
	if toolCall != "" {
		// HTML-decode the tool call string to handle encoded characters like &lt; -> <=
		decodedToolCall := html.UnescapeString(toolCall)
		openAIToolMap, err := convertJSONToMapStringString(decodedToolCall)
		if err != nil {
			logger.Error("failed to unmarshal openai tool call", "call", decodedToolCall, "error", err)
			// Ensure lastToolCall.ID is set for the error response (already set from chunk)
			// Send error response to LLM so it can retry or handle the error
			toolResponseMsg := models.RoleMsg{
				Role:       cfg.ToolRole,
				Content:    fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err),
				ToolCallID: lastToolCall.ID, // Use the stored tool call ID
			}
			chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
			// Clear the stored tool call ID after using it (no longer needed)
			// Trigger the assistant to continue processing with the error message
			crr := &models.ChatRoundReq{
				Role: cfg.AssistantRole,
			}
			// provoke next llm msg after failed tool call
			chatRoundChan <- crr
			// chatRound("", cfg.AssistantRole, tv, false, false)
			return true
		}
		lastToolCall.Args = openAIToolMap
		fc = lastToolCall
		// NOTE: We do NOT override lastToolCall.ID from arguments.
		// The ID should come from the streaming response (chunk.ToolID) set earlier.
		// Some tools like todo_create have "id" in their arguments which is NOT the tool call ID.
	} else {
		jsStr := models.ToolCallRE.FindString(msg)
		if jsStr == "" { // no tool call case
			return false
		}
		// Remove prefix/suffix with flexible whitespace handling
		jsStr = strings.TrimSpace(jsStr)
		jsStr = strings.TrimPrefix(jsStr, "__tool_call__")
		jsStr = strings.TrimSuffix(jsStr, "__tool_call__")
		jsStr = strings.TrimSpace(jsStr)
		// HTML-decode the JSON string to handle encoded characters like &lt; -> <=
		decodedJsStr := html.UnescapeString(jsStr)
		// Try to find valid JSON bounds (first { to last })
		start := strings.Index(decodedJsStr, "{")
		end := strings.LastIndex(decodedJsStr, "}")
		if start == -1 || end == -1 || end <= start {
			logger.Error("failed to find valid JSON in tool call", "json_string", decodedJsStr)
			toolResponseMsg := models.RoleMsg{
				Role:    cfg.ToolRole,
				Content: "Error processing tool call: no valid JSON found. Please check the JSON format.",
			}
			chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
			crr := &models.ChatRoundReq{
				Role: cfg.AssistantRole,
			}
			chatRoundChan <- crr
			return true
		}
		decodedJsStr = decodedJsStr[start : end+1]
		var err error
		fc, err = unmarshalFuncCall(decodedJsStr)
		if err != nil {
			logger.Error("failed to unmarshal tool call", "error", err, "json_string", decodedJsStr)
			// Send error response to LLM so it can retry or handle the error
			toolResponseMsg := models.RoleMsg{
				Role:    cfg.ToolRole,
				Content: fmt.Sprintf("Error processing tool call: %v. Please check the JSON format and try again.", err),
			}
			chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
			logger.Debug("findCall: added tool error response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "message_count_after_add", len(chatBody.Messages))
			// Trigger the assistant to continue processing with the error message
			// chatRound("", cfg.AssistantRole, tv, false, false)
			crr := &models.ChatRoundReq{
				Role: cfg.AssistantRole,
			}
			// provoke next llm msg after failed tool call
			chatRoundChan <- crr
			return true
		}
		// Update lastToolCall with parsed function call
		lastToolCall.ID = fc.ID
		lastToolCall.Name = fc.Name
		lastToolCall.Args = fc.Args
	}
	// we got here => last msg recognized as a tool call (correct or not)
	// Use the tool call ID from streaming response (lastToolCall.ID)
	// Don't generate random ID - the ID should match between assistant message and tool response
	lastMsgIdx := len(chatBody.Messages) - 1
	if lastToolCall.ID != "" {
		chatBody.Messages[lastMsgIdx].ToolCallID = lastToolCall.ID
	}
	// Store tool call info in the assistant message
	// Convert Args map to JSON string for storage
	chatBody.Messages[lastMsgIdx].ToolCall = &models.ToolCall{
		ID:   lastToolCall.ID,
		Name: lastToolCall.Name,
		Args: mapToString(lastToolCall.Args),
	}
	// call a func
	// _, ok := tools.FnMap[fc.Name]
	// if !ok {
	// 	m := fc.Name + " is not implemented"
	// 	// Create tool response message with the proper tool_call_id
	// 	toolResponseMsg := models.RoleMsg{
	// 		Role:       cfg.ToolRole,
	// 		Content:    m,
	// 		ToolCallID: lastToolCall.ID, // Use the stored tool call ID
	// 	}
	// 	chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
	// 	logger.Debug("findCall: added tool not implemented response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages))
	// 	// Clear the stored tool call ID after using it
	// 	lastToolCall.ID = ""
	// 	// Trigger the assistant to continue processing with the new tool response
	// 	// by calling chatRound with empty content to continue the assistant's response
	// 	crr := &models.ChatRoundReq{
	// 		Role: cfg.AssistantRole,
	// 	}
	// 	// failed to find tool
	// 	chatRoundChan <- crr
	// 	return true
	// }
	// Show tool call progress indicator before execution
	outputHandler.Writef("\n[yellow::i][tool: %s...][-:-:-]", fc.Name)
	toolRunningMode.Store(true)
	resp, okT := tools.CallToolWithAgent(fc.Name, fc.Args)
	if !okT {
		// Create tool response message with the proper tool_call_id
		toolResponseMsg := models.RoleMsg{
			Role:       cfg.ToolRole,
			Content:    string(resp),
			ToolCallID: lastToolCall.ID, // Use the stored tool call ID
		}
		chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
		logger.Debug("findCall: added tool not implemented response", "role", toolResponseMsg.Role,
			"content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID)
		// Clear the stored tool call ID after using it
		lastToolCall.ID = ""
		// Trigger the assistant to continue processing with the new tool response
		// by calling chatRound with empty content to continue the assistant's response
		crr := &models.ChatRoundReq{
			Role: cfg.AssistantRole,
		}
		// failed to find tool
		chatRoundChan <- crr
		return true
	}
	toolRunningMode.Store(false)
	toolMsg := string(resp)
	logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg)
	// Create tool response message with the proper tool_call_id
	// Mark shell commands as always visible
	isShellCommand := fc.Name == "execute_command"
	// Check if response is multimodal content (image)
	var toolResponseMsg models.RoleMsg
	if strings.HasPrefix(strings.TrimSpace(toolMsg), `{"type":"multimodal_content"`) {
		// Parse multimodal content response
		multimodalResp := models.MultimodalToolResp{}
		if err := json.Unmarshal([]byte(toolMsg), &multimodalResp); err == nil && multimodalResp.Type == "multimodal_content" {
			// Create RoleMsg with ContentParts
			var contentParts []any
			for _, part := range multimodalResp.Parts {
				partType := part["type"]
				switch partType {
				case "text":
					contentParts = append(contentParts, models.TextContentPart{Type: "text", Text: part["text"]})
				case "image_url":
					contentParts = append(contentParts, models.ImageContentPart{
						Type: "image_url",
						ImageURL: struct {
							URL string `json:"url"`
						}{URL: part["url"]},
					})
				default:
					continue
				}
			}
			toolResponseMsg = models.RoleMsg{
				Role:            cfg.ToolRole,
				ContentParts:    contentParts,
				HasContentParts: true,
				ToolCallID:      lastToolCall.ID,
				IsShellCommand:  isShellCommand,
			}
		} else {
			// Fallback to regular content
			toolResponseMsg = models.RoleMsg{
				Role:           cfg.ToolRole,
				Content:        toolMsg,
				ToolCallID:     lastToolCall.ID,
				IsShellCommand: isShellCommand,
			}
		}
	} else {
		toolResponseMsg = models.RoleMsg{
			Role:           cfg.ToolRole,
			Content:        toolMsg,
			ToolCallID:     lastToolCall.ID,
			IsShellCommand: isShellCommand,
		}
	}
	outputHandler.Writef("%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
		"\n\n", len(chatBody.Messages), cfg.ToolRole, toolResponseMsg.GetText())
	chatBody.Messages = append(chatBody.Messages, toolResponseMsg)
	// Clear the stored tool call ID after using it
	lastToolCall.ID = ""
	// Trigger the assistant to continue processing with the new tool response
	// by calling chatRound with empty content to continue the assistant's response
	crr := &models.ChatRoundReq{
		Role: cfg.AssistantRole,
	}
	chatRoundChan <- crr
	return true
}

func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string {
	resp := make([]string, len(messages))
	for i := range messages {
		icon := fmt.Sprintf("[-:-:b](%d) <%s>:[-:-:-]", i, messages[i].Role)
		// Handle tool call indicators (assistant messages with tool call but empty content)
		if messages[i].Role == cfg.AssistantRole && messages[i].ToolCall != nil && messages[i].ToolCall.ID != "" {
			// This is a tool call indicator - show collapsed
			if toolCollapsed {
				toolName := messages[i].ToolCall.Name
				argsPreview := messages[i].ToolCall.Args
				if len(messages[i].ToolCall.Args) > 30 {
					argsPreview = messages[i].ToolCall.Args[:30]
				}
				resp[i] = strings.ReplaceAll(
					fmt.Sprintf(
						"%s\n%s\n[yellow::i][tool call: %s %s (press Ctrl+T to expand)][-:-:-]\n",
						icon, messages[i].GetText(), toolName, argsPreview),
					"\n\n", "\n")
			} else {
				// Show full tool call info
				toolName := messages[i].ToolCall.Name
				resp[i] = strings.ReplaceAll(
					fmt.Sprintf(
						"%s\n%s\n[yellow::i][tool call: %s][-:-:-]\nargs: %s\nid: %s\n",
						icon, messages[i].GetText(), toolName, messages[i].ToolCall.Args, messages[i].ToolCall.ID),
					"\n\n", "\n")
			}
			continue
		}
		// Handle tool responses
		if messages[i].Role == cfg.ToolRole || messages[i].Role == "tool" {
			// Always show shell commands
			if messages[i].IsShellCommand {
				resp[i] = MsgToText(i, &messages[i])
				continue
			}
			// Hide non-shell tool responses when collapsed
			if toolCollapsed {
				resp[i] = icon + "\n[yellow::i][tool resp (press Ctrl+T to expand)][-:-:-]\n"
				continue
			}
			// When expanded, show tool responses
			resp[i] = MsgToText(i, &messages[i])
			continue
		}
		// INFO: skips system msg when showSys is false
		if !showSys && messages[i].Role == "system" {
			continue
		}
		resp[i] = MsgToText(i, &messages[i])
	}
	return resp
}

func chatToText(messages []models.RoleMsg, showSys bool) string {
	s := chatToTextSlice(messages, showSys)
	text := strings.Join(s, "\n")
	// Collapse thinking blocks if enabled
	if thinkingCollapsed {
		text = models.ThinkRE.ReplaceAllStringFunc(text, func(match string) string {
			// Extract content between <think> and </think>
			start := len("<think>")
			end := len(match) - len("</think>")
			if start < end && start < len(match) {
				content := match[start:end]
				return fmt.Sprintf("[yellow::i][thinking... (%d chars) (press Alt+T to expand)][-:-:-]", len(content))
			}
			return "[yellow::i][thinking... (press Alt+T to expand)][-:-:-]"
		})
		// Handle incomplete thinking blocks (during streaming when </think> hasn't arrived yet)
		if strings.Contains(text, "<think>") && !strings.Contains(text, "</think>") {
			// Find the incomplete thinking block and replace it
			startIdx := strings.Index(text, "<think>")
			if startIdx != -1 {
				content := text[startIdx+len("<think>"):]
				placeholder := fmt.Sprintf(
					"[yellow::i][thinking... (%d chars) (press Alt+T to expand)][-:-:-]",
					len(content))
				text = text[:startIdx] + placeholder
			}
		}
	}
	return text
}

func addNewChat(chatName string) {
	id, err := store.ChatGetMaxID()
	if err != nil {
		logger.Error("failed to get max chat id from db;", "id:", id)
		// INFO: will rewrite first chat
	}
	chat := &models.Chat{
		ID:        id + 1,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
		Agent:     cfg.AssistantRole,
	}
	if chatName == "" {
		chatName = fmt.Sprintf("%d_%s", chat.ID, cfg.AssistantRole)
	}
	chat.Name = chatName
	chatMap[chat.Name] = chat
	activeChatName = chat.Name
}

func applyCharCard(cc *models.CharCard, loadHistory bool) {
	cfg.AssistantRole = cc.Role
	history, err := loadAgentsLastChat(cfg.AssistantRole)
	if err != nil || !loadHistory {
		// too much action for err != nil; loadAgentsLastChat needs to be split up
		history = []models.RoleMsg{
			{Role: "system", Content: cc.SysPrompt},
			{Role: cfg.AssistantRole, Content: cc.FirstMsg},
		}
		logger.Warn("failed to load last agent chat;", "agent", cc.Role, "err", err, "new_history", history)
		addNewChat("")
	}
	chatBody.Messages = history
}

func charToStart(agentName string, keepSysP bool) bool {
	cc := GetCardByRole(agentName)
	if cc == nil {
		return false
	}
	applyCharCard(cc, keepSysP)
	return true
}

func updateModelLists() {
	var err error
	if cfg.OpenRouterToken != "" {
		ORFreeModels, err = fetchORModels(true)
		if err != nil {
			logger.Warn("failed to fetch or models", "error", err)
		}
	}
	// if llama.cpp started after gf-lt?
	ml, err := fetchLCPModelsWithLoadStatus()
	if err != nil {
		logger.Warn("failed to fetch llama.cpp models", "error", err)
	}
	localModelsMu.Lock()
	LocalModels = ml
	localModelsMu.Unlock()
	// set already loaded model in llama.cpp
	if !isLocalLlamacpp() {
		return
	}
	localModelsMu.Lock()
	defer localModelsMu.Unlock()
	for i := range LocalModels {
		if strings.Contains(LocalModels[i], models.LoadedMark) {
			m := strings.TrimPrefix(LocalModels[i], models.LoadedMark)
			cfg.CurrentModel = m
			chatBody.Model = m
			cachedModelColor.Store("green")
			updateStatusLine()
			UpdateToolCapabilities()
			if !cfg.CLIMode {
				app.Draw() // raw?
			}
			return
		}
	}
}

func refreshLocalModelsIfEmpty() {
	localModelsMu.RLock()
	if len(LocalModels) > 0 {
		localModelsMu.RUnlock()
		return
	}
	localModelsMu.RUnlock()
	// try to fetch
	models, err := fetchLCPModels()
	if err != nil {
		logger.Warn("failed to fetch llama.cpp models", "error", err)
		return
	}
	localModelsMu.Lock()
	LocalModels = models
	localModelsMu.Unlock()
}

func startNewCLIChat() []models.RoleMsg {
	id, err := store.ChatGetMaxID()
	if err != nil {
		logger.Error("failed to get chat id", "error", err)
	}
	id++
	charToStart(cfg.AssistantRole, false)
	newChat := &models.Chat{
		ID:        id,
		Name:      fmt.Sprintf("%d_%s", id, cfg.AssistantRole),
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
		Msgs:      "",
		Agent:     cfg.AssistantRole,
	}
	activeChatName = newChat.Name
	chatMap[newChat.Name] = newChat
	cliPrevOutput = ""
	return chatBody.Messages
}

func startNewCLIErrors() []models.RoleMsg {
	return startNewCLIChat()
}

func summarizeAndStartNewChat() {
	if len(chatBody.Messages) == 0 {
		showToast("info", "No chat history to summarize")
		return
	}
	showToast("info", "Summarizing chat history...")
	arg := map[string]string{
		"chat": chatToText(chatBody.Messages, false),
	}
	// Call the summarize_chat tool via agent
	summaryBytes, _ := tools.CallToolWithAgent("summarize_chat", arg)
	summary := string(summaryBytes)
	if summary == "" {
		showToast("error", "Failed to generate summary")
		return
	}
	// Start a new chat
	startNewChat(true)
	// Inject summary as a tool call response
	toolMsg := models.RoleMsg{
		Role:       cfg.ToolRole,
		Content:    summary,
		ToolCallID: "",
	}
	chatBody.Messages = append(chatBody.Messages, toolMsg)
	// Update UI
	if !cfg.CLIMode {
		textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys))
		colorText()
	}
	// Update storage
	if err := updateStorageChat(activeChatName, chatBody.Messages); err != nil {
		logger.Warn("failed to update storage after injecting summary", "error", err)
	}
	showToast("info", "Chat summarized and new chat started with summary as tool response")
}

func init() {
	// ctx, cancel := context.WithCancel(context.Background())
	var err error
	cfg, err = config.LoadConfig("config.toml")
	if err != nil {
		fmt.Println("failed to load config.toml", err)
		cancel()
		os.Exit(1)
		return
	}
	defaultStarter = []models.RoleMsg{
		{Role: "system", Content: models.BasicSysMsg},
		{Role: cfg.AssistantRole, Content: models.DefaultFirstMsg},
	}
	logfile, err := os.OpenFile(cfg.LogFile,
		os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		slog.Error("failed to open log file", "error", err, "filename", cfg.LogFile)
		cancel()
		os.Exit(1)
		return
	}
	// load cards
	sysMap[basicCard.ID] = basicCard
	roleToID["assistant"] = basicCard.ID
	basicCard.Role = cfg.AssistantRole
	logLevel.Set(slog.LevelInfo)
	logger = slog.New(slog.NewTextHandler(logfile, &slog.HandlerOptions{Level: logLevel}))
	store = storage.NewProviderSQL(cfg.DBPATH, logger)
	if store == nil {
		cancel()
		os.Exit(1)
		return
	}
	ragger, err = rag.New(logger, store, cfg)
	if err != nil {
		logger.Error("failed to create RAG", "error", err)
	}
	if ragger != nil && ragger.FallbackMessage() != "" && app != nil {
		showToast("RAG", "ONNX unavailable, using API: "+ragger.FallbackMessage())
	}
	// https://github.com/coreydaley/ggerganov-llama.cpp/blob/master/examples/server/README.md
	// load all chats in memory
	if _, err := loadHistoryChats(); err != nil {
		logger.Error("failed to load chat", "error", err)
		cancel()
		os.Exit(1)
		return
	}
	lastToolCall = &models.FuncCall{}
	var lastChat []models.RoleMsg
	if cfg.CLIMode {
		lastChat = startNewCLIErrors()
	} else {
		lastChat = loadOldChatOrGetNew()
	}
	chatBody = &models.ChatBody{
		Model:    "modelname",
		Stream:   true,
		Messages: lastChat,
	}
	choseChunkParser()
	httpClient = createClient(time.Second * 90)
	if cfg.TTS_ENABLED {
		orator = NewOrator(logger, cfg)
	}
	if cfg.STT_ENABLED {
		asr = NewSTT(logger, cfg)
	}
	if cfg.PlaywrightEnabled {
		go func() {
			if err := tools.CheckPlaywright(); err != nil {
				if err := tools.InstallPW(); err != nil {
					logger.Error("failed to install playwright", "error", err)
					cancel()
					os.Exit(1)
					return
				}
				if err := tools.CheckPlaywright(); err != nil {
					logger.Error("failed to run playwright", "error", err)
					cancel()
					os.Exit(1)
					return
				}
			}
		}()
	}
	// atomic default values
	cachedModelColor.Store("orange")
	go chatWatcher(ctx)
}
