package bot

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"time"

	"reasonix/internal/agent"
	"reasonix/internal/proc"
	"reasonix/internal/secrets"
)

const (
	botProjectListLimit = 20
	botSessionListLimit = 20
	botSearchListLimit  = 20
)

type botProjectEntry struct {
	ID      string
	Name    string
	Root    string
	Sources []string
}

type botSessionEntry struct {
	ID             string
	ProjectID      string
	ProjectName    string
	WorkspaceRoot  string
	SessionID      string
	SessionPath    string
	RemoteID       string
	ConnectionID   string
	ChatType       string
	UserID         string
	ThreadID       string
	Scope          string
	Preview        string
	TopicTitle     string
	LastActivityAt time.Time
	Source         string
}

type botProjectSearchResult struct {
	ProjectID   string
	ProjectName string
	Path        string
	Line        int
	Text        string
}

func (gw *BotGateway) buildProjectIndex() []botProjectEntry {
	collector := newBotProjectCollector()
	collector.add(gw.cfg.WorkspaceRoot, "default")

	// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime,
	// so the whole scan shares the controllers critical section below.
	gw.mu.Lock()
	platforms := make([]string, 0, len(gw.cfg.Channels))
	for platform := range gw.cfg.Channels {
		platforms = append(platforms, string(platform))
	}
	sort.Strings(platforms)
	for _, platform := range platforms {
		channel := gw.cfg.Channels[Platform(platform)]
		source := "channel:" + platform
		collector.add(channel.WorkspaceRoot, source)
		addMappingProjects(collector, channel, source)
	}

	connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
	for id := range gw.cfg.ConnectionChannels {
		connections = append(connections, id)
	}
	sort.Strings(connections)
	for _, id := range connections {
		channel := gw.cfg.ConnectionChannels[id]
		source := "connection:" + id
		collector.add(channel.WorkspaceRoot, source)
		addMappingProjects(collector, channel, source)
	}

	for i, route := range gw.cfg.Routes {
		collector.add(route.Channel.WorkspaceRoot, fmt.Sprintf("route:%d", i+1))
	}

	for key, state := range gw.controllers {
		root := ""
		if state != nil {
			root = state.workspaceRoot
			if root == "" && state.ctrl != nil {
				root = state.ctrl.WorkspaceRoot()
			}
		}
		collector.add(root, "active:"+shortBotID(key))
	}
	for key, override := range gw.sessionOverrides {
		collector.add(override.channel.WorkspaceRoot, "override:"+shortBotID(key))
	}
	gw.mu.Unlock()

	return collector.entries()
}

func addMappingProjects(collector *botProjectCollector, channel ChannelConfig, source string) {
	for _, mapping := range channel.SessionMappings {
		root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
		collector.add(root, source+":mapping:"+strings.TrimSpace(mapping.RemoteID))
	}
}

type botProjectCollector struct {
	byRoot map[string]*botProjectEntry
}

func newBotProjectCollector() *botProjectCollector {
	return &botProjectCollector{byRoot: make(map[string]*botProjectEntry)}
}

func (c *botProjectCollector) add(root, source string) {
	root = canonicalBotPath(root)
	if root == "" {
		return
	}
	entry := c.byRoot[root]
	if entry == nil {
		entry = &botProjectEntry{Name: botProjectName(root), Root: root}
		c.byRoot[root] = entry
	}
	source = strings.TrimSpace(source)
	if source == "" {
		return
	}
	if slices.Contains(entry.Sources, source) {
		return
	}
	entry.Sources = append(entry.Sources, source)
}

func (c *botProjectCollector) entries() []botProjectEntry {
	out := make([]botProjectEntry, 0, len(c.byRoot))
	for _, entry := range c.byRoot {
		copied := *entry
		sort.Strings(copied.Sources)
		out = append(out, copied)
	}
	sort.Slice(out, func(i, j int) bool {
		li := strings.ToLower(out[i].Name)
		lj := strings.ToLower(out[j].Name)
		if li != lj {
			return li < lj
		}
		return out[i].Root < out[j].Root
	})
	for i := range out {
		out[i].ID = fmt.Sprintf("p%d", i+1)
	}
	return out
}

func (gw *BotGateway) buildSessionIndex(projects []botProjectEntry) []botSessionEntry {
	projectByRoot := make(map[string]botProjectEntry, len(projects))
	for _, project := range projects {
		projectByRoot[canonicalBotPath(project.Root)] = project
	}
	collector := newBotSessionCollector(projectByRoot)

	// cfg.Channels / cfg.ConnectionChannels are rewritten under gw.mu at runtime;
	// collect the mapping-derived entries under a short lock, keeping the
	// filesystem scan below outside it.
	gw.mu.Lock()
	platforms := make([]string, 0, len(gw.cfg.Channels))
	for platform := range gw.cfg.Channels {
		platforms = append(platforms, string(platform))
	}
	sort.Strings(platforms)
	for _, platform := range platforms {
		channel := gw.cfg.Channels[Platform(platform)]
		addMappingSessions(collector, channel, "", "channel:"+platform)
	}

	connections := make([]string, 0, len(gw.cfg.ConnectionChannels))
	for id := range gw.cfg.ConnectionChannels {
		connections = append(connections, id)
	}
	sort.Strings(connections)
	for _, id := range connections {
		channel := gw.cfg.ConnectionChannels[id]
		addMappingSessions(collector, channel, id, "connection:"+id)
	}
	gw.mu.Unlock()

	for _, project := range projects {
		dir := botSessionDir(project.Root)
		if dir == "" {
			continue
		}
		if info, err := os.Stat(dir); err != nil || !info.IsDir() {
			continue
		}
		infos, err := agent.ListSessions(dir)
		if err != nil {
			gw.logger.Warn("bot project session index failed", "project", project.Name, "err", err)
			continue
		}
		for _, info := range infos {
			collector.add(botSessionEntry{
				WorkspaceRoot:  project.Root,
				SessionPath:    canonicalBotPath(info.Path),
				SessionID:      botSessionTarget(info.Path),
				Scope:          firstNonEmptyString(info.Scope, "project"),
				Preview:        info.Preview,
				TopicTitle:     info.TopicTitle,
				LastActivityAt: info.LastActivityAt,
				Source:         "project-sessions",
			})
		}
	}

	return collector.entries()
}

func addMappingSessions(collector *botSessionCollector, channel ChannelConfig, connectionID, source string) {
	for _, mapping := range channel.SessionMappings {
		sessionID := strings.TrimSpace(mapping.SessionID)
		sessionPath := botSessionPathFromTarget(sessionID)
		root := workspaceRootForSessionMapping(mapping, channel.WorkspaceRoot)
		collector.add(botSessionEntry{
			WorkspaceRoot:  root,
			SessionID:      sessionID,
			SessionPath:    sessionPath,
			RemoteID:       strings.TrimSpace(mapping.RemoteID),
			ConnectionID:   strings.TrimSpace(connectionID),
			ChatType:       strings.TrimSpace(mapping.ChatType),
			UserID:         strings.TrimSpace(mapping.UserID),
			ThreadID:       strings.TrimSpace(mapping.ThreadID),
			Scope:          strings.TrimSpace(mapping.Scope),
			LastActivityAt: parseBotMappingUpdatedAt(mapping.UpdatedAt),
			Source:         source,
		})
	}
}

type botSessionCollector struct {
	projectByRoot map[string]botProjectEntry
	byKey         map[string]*botSessionEntry
}

func newBotSessionCollector(projectByRoot map[string]botProjectEntry) *botSessionCollector {
	return &botSessionCollector{
		projectByRoot: projectByRoot,
		byKey:         make(map[string]*botSessionEntry),
	}
}

func (c *botSessionCollector) add(entry botSessionEntry) {
	entry.WorkspaceRoot = canonicalBotPath(entry.WorkspaceRoot)
	entry.SessionPath = canonicalBotPath(entry.SessionPath)
	if entry.SessionID == "" && entry.SessionPath != "" {
		entry.SessionID = botSessionTarget(entry.SessionPath)
	}
	if entry.SessionPath == "" && entry.SessionID == "" && entry.RemoteID == "" {
		return
	}
	if project, ok := c.projectByRoot[entry.WorkspaceRoot]; ok {
		entry.ProjectID = project.ID
		entry.ProjectName = project.Name
	}
	key := entry.SessionPath
	if key == "" {
		key = strings.Join([]string{"target", entry.ConnectionID, entry.RemoteID, entry.ChatType, entry.UserID, entry.ThreadID, entry.SessionID}, "\x00")
	}
	existing := c.byKey[key]
	if existing == nil {
		c.byKey[key] = &entry
		return
	}
	mergeBotSessionEntry(existing, entry)
}

func mergeBotSessionEntry(dst *botSessionEntry, src botSessionEntry) {
	if dst.ProjectID == "" {
		dst.ProjectID = src.ProjectID
	}
	if dst.ProjectName == "" {
		dst.ProjectName = src.ProjectName
	}
	if dst.WorkspaceRoot == "" {
		dst.WorkspaceRoot = src.WorkspaceRoot
	}
	if dst.SessionID == "" {
		dst.SessionID = src.SessionID
	}
	if dst.SessionPath == "" {
		dst.SessionPath = src.SessionPath
	}
	if dst.RemoteID == "" {
		dst.RemoteID = src.RemoteID
	}
	if dst.ConnectionID == "" {
		dst.ConnectionID = src.ConnectionID
	}
	if dst.ChatType == "" {
		dst.ChatType = src.ChatType
	}
	if dst.UserID == "" {
		dst.UserID = src.UserID
	}
	if dst.ThreadID == "" {
		dst.ThreadID = src.ThreadID
	}
	if dst.Scope == "" {
		dst.Scope = src.Scope
	}
	if dst.Preview == "" {
		dst.Preview = src.Preview
	}
	if dst.TopicTitle == "" {
		dst.TopicTitle = src.TopicTitle
	}
	if src.LastActivityAt.After(dst.LastActivityAt) {
		dst.LastActivityAt = src.LastActivityAt
	}
	if dst.Source == "" {
		dst.Source = src.Source
	} else if src.Source != "" && !strings.Contains(dst.Source, src.Source) {
		dst.Source += "," + src.Source
	}
}

func (c *botSessionCollector) entries() []botSessionEntry {
	out := make([]botSessionEntry, 0, len(c.byKey))
	for _, entry := range c.byKey {
		out = append(out, *entry)
	}
	sort.Slice(out, func(i, j int) bool {
		if !out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
			return out[i].LastActivityAt.After(out[j].LastActivityAt)
		}
		if out[i].ProjectName != out[j].ProjectName {
			return out[i].ProjectName < out[j].ProjectName
		}
		return out[i].SessionPath < out[j].SessionPath
	})
	for i := range out {
		out[i].ID = fmt.Sprintf("s%d", i+1)
	}
	return out
}

func botSessionPathFromTarget(target string) string {
	target = strings.TrimSpace(target)
	if target == "" {
		return ""
	}
	if after, ok := strings.CutPrefix(target, "path:"); ok {
		return canonicalBotPath(after)
	}
	if filepath.IsAbs(target) && strings.HasSuffix(target, ".jsonl") {
		return canonicalBotPath(target)
	}
	return ""
}

func parseBotMappingUpdatedAt(value string) time.Time {
	value = strings.TrimSpace(value)
	if value == "" {
		return time.Time{}
	}
	if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
		return t
	}
	if t, err := time.Parse(time.RFC3339, value); err == nil {
		return t
	}
	return time.Time{}
}

func resolveBotProject(projects []botProjectEntry, selector string) (botProjectEntry, []botProjectEntry) {
	selector = strings.TrimSpace(selector)
	if selector == "" {
		return botProjectEntry{}, nil
	}
	selectorLower := strings.ToLower(selector)
	canonicalSelector := canonicalBotPath(selector)
	var matches []botProjectEntry
	for _, project := range projects {
		if strings.EqualFold(project.ID, selector) || canonicalBotPath(project.Root) == canonicalSelector || strings.EqualFold(project.Name, selector) {
			return project, nil
		}
		if strings.Contains(strings.ToLower(project.Name), selectorLower) || strings.Contains(strings.ToLower(project.Root), selectorLower) {
			matches = append(matches, project)
		}
	}
	if len(matches) == 1 {
		return matches[0], nil
	}
	return botProjectEntry{}, matches
}

func resolveBotSession(sessions []botSessionEntry, selector string) (botSessionEntry, []botSessionEntry) {
	selector = strings.TrimSpace(selector)
	if selector == "" {
		return botSessionEntry{}, nil
	}
	selectorLower := strings.ToLower(selector)
	canonicalSelector := canonicalBotPath(selector)
	var matches []botSessionEntry
	for _, session := range sessions {
		if strings.EqualFold(session.ID, selector) ||
			(session.SessionPath != "" && canonicalBotPath(session.SessionPath) == canonicalSelector) ||
			(session.SessionPath != "" && strings.EqualFold(filepath.Base(session.SessionPath), selector)) ||
			(session.SessionID != "" && strings.EqualFold(session.SessionID, selector)) {
			return session, nil
		}
		if botSessionMatchesQuery(session, selectorLower) {
			matches = append(matches, session)
		}
	}
	if len(matches) == 1 {
		return matches[0], nil
	}
	return botSessionEntry{}, matches
}

func filterBotProjects(projects []botProjectEntry, query string) []botProjectEntry {
	query = strings.ToLower(strings.TrimSpace(query))
	if query == "" {
		return projects
	}
	var out []botProjectEntry
	for _, project := range projects {
		if strings.Contains(strings.ToLower(project.Name+" "+project.Root+" "+strings.Join(project.Sources, " ")), query) {
			out = append(out, project)
		}
	}
	return out
}

func filterBotSessions(sessions []botSessionEntry, query string) []botSessionEntry {
	query = strings.ToLower(strings.TrimSpace(query))
	if query == "" {
		return sessions
	}
	var out []botSessionEntry
	for _, session := range sessions {
		if botSessionMatchesQuery(session, query) {
			out = append(out, session)
		}
	}
	return out
}

func botSessionMatchesQuery(session botSessionEntry, query string) bool {
	haystack := strings.ToLower(strings.Join([]string{
		session.ID,
		session.ProjectID,
		session.ProjectName,
		session.WorkspaceRoot,
		session.SessionID,
		session.SessionPath,
		session.RemoteID,
		session.ConnectionID,
		session.ChatType,
		session.UserID,
		session.ThreadID,
		session.Scope,
		session.Preview,
		session.TopicTitle,
		session.Source,
	}, " "))
	return strings.Contains(haystack, query)
}

func formatBotProjects(projects []botProjectEntry, query string, limit int) string {
	matches := filterBotProjects(projects, query)
	if len(matches) == 0 {
		if strings.TrimSpace(query) == "" {
			return "还没有可用项目索引。请先在 bot 连接、route 或当前会话里配置 workspace_root。"
		}
		return "没有匹配的项目。"
	}
	if limit <= 0 || limit > len(matches) {
		limit = len(matches)
	}
	var b strings.Builder
	fmt.Fprintf(&b, "项目索引（%d/%d）：", limit, len(matches))
	for i := range limit {
		project := matches[i]
		fmt.Fprintf(&b, "\n%s %s — %s", project.ID, project.Name, displayBotPath(project.Root))
		if len(project.Sources) > 0 {
			fmt.Fprintf(&b, "\n  来源: %s", strings.Join(project.Sources, ", "))
		}
	}
	if len(matches) > limit {
		fmt.Fprintf(&b, "\n还有 %d 个结果，请加关键词缩小范围。", len(matches)-limit)
	}
	return b.String()
}

func formatBotSessions(sessions []botSessionEntry, query string, limit int) string {
	matches := filterBotSessions(sessions, query)
	if len(matches) == 0 {
		if strings.TrimSpace(query) == "" {
			return "还没有可用会话索引。已有项目会话或 bot session_mappings 后会出现在这里。"
		}
		return "没有匹配的会话。"
	}
	if limit <= 0 || limit > len(matches) {
		limit = len(matches)
	}
	var b strings.Builder
	fmt.Fprintf(&b, "会话索引（%d/%d）：", limit, len(matches))
	for i := range limit {
		session := matches[i]
		project := firstNonEmptyString(session.ProjectName, "global")
		fmt.Fprintf(&b, "\n%s %s", session.ID, project)
		if session.TopicTitle != "" {
			fmt.Fprintf(&b, " · %s", singleLineBotText(session.TopicTitle, 40))
		}
		if session.Preview != "" {
			fmt.Fprintf(&b, "\n  预览: %s", singleLineBotText(session.Preview, 90))
		}
		if session.SessionPath != "" {
			fmt.Fprintf(&b, "\n  文件: %s", displayBotPath(session.SessionPath))
		} else if session.SessionID != "" {
			fmt.Fprintf(&b, "\n  目标: %s", session.SessionID)
		}
		if session.RemoteID != "" || session.ConnectionID != "" {
			fmt.Fprintf(&b, "\n  远端: %s %s", session.ConnectionID, session.RemoteID)
		}
	}
	if len(matches) > limit {
		fmt.Fprintf(&b, "\n还有 %d 个结果，请加关键词缩小范围。", len(matches)-limit)
	}
	return b.String()
}

func formatBotProjectSearchResults(results []botProjectSearchResult, limit int) string {
	if len(results) == 0 {
		return "没有跨项目命中。"
	}
	if limit <= 0 || limit > len(results) {
		limit = len(results)
	}
	var b strings.Builder
	fmt.Fprintf(&b, "跨项目检索结果（%d/%d）：", limit, len(results))
	for i := range limit {
		result := results[i]
		project := firstNonEmptyString(result.ProjectName, result.ProjectID)
		fmt.Fprintf(&b, "\n- %s %s:%d: %s", project, displayBotPath(result.Path), result.Line, singleLineBotText(result.Text, 120))
	}
	if len(results) > limit {
		fmt.Fprintf(&b, "\n还有 %d 条命中，请加关键词缩小范围。", len(results)-limit)
	}
	return b.String()
}

func searchBotProjects(ctx context.Context, projects []botProjectEntry, query string, limit int) ([]botProjectSearchResult, error) {
	query = strings.TrimSpace(query)
	if len([]rune(query)) < 2 {
		return nil, errors.New("检索词至少需要 2 个字符")
	}
	var roots []string
	seen := map[string]bool{}
	for _, project := range projects {
		root := canonicalBotPath(project.Root)
		if root == "" || seen[root] {
			continue
		}
		info, err := os.Stat(root)
		if err != nil || !info.IsDir() {
			continue
		}
		seen[root] = true
		roots = append(roots, root)
	}
	if len(roots) == 0 {
		return nil, errors.New("没有可检索的项目目录")
	}
	if limit <= 0 {
		limit = botSearchListLimit
	}
	if rg, err := exec.LookPath("rg"); err == nil {
		return searchBotProjectsWithRG(ctx, rg, projects, roots, query, limit)
	}
	return searchBotProjectsFallback(ctx, projects, roots, query, limit)
}

func searchBotProjectsWithRG(ctx context.Context, rg string, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
	args := []string{
		"--json",
		"--color", "never",
		"--fixed-strings",
		"--max-count", "3",
		"--max-filesize", "1M",
		"--glob", "!.git",
		"--glob", "!node_modules",
		"--glob", "!dist",
		"--glob", "!build",
		"--glob", "!vendor",
		"--",
		query,
	}
	args = append(args, roots...)
	cmd := proc.CommandContext(ctx, rg, args...)
	cmd.Env = secrets.ProcessEnv()
	// The desktop app hosts bot bridges in the GUI process; without this an
	// rg search flashes a console window on Windows.
	proc.HideWindow(cmd)
	out, err := cmd.Output()
	if err != nil {
		var exitErr *exec.ExitError
		if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
			return nil, nil
		}
		return nil, fmt.Errorf("rg failed: %w", err)
	}
	dec := json.NewDecoder(bytes.NewReader(out))
	var results []botProjectSearchResult
	for {
		var item struct {
			Type string `json:"type"`
			Data struct {
				Path struct {
					Text string `json:"text"`
				} `json:"path"`
				Lines struct {
					Text string `json:"text"`
				} `json:"lines"`
				LineNumber int `json:"line_number"`
			} `json:"data"`
		}
		if err := dec.Decode(&item); err != nil {
			if errors.Is(err, io.EOF) {
				break
			}
			break
		}
		if item.Type != "match" {
			continue
		}
		path := canonicalBotPath(item.Data.Path.Text)
		project := botProjectForPath(projects, path)
		results = append(results, botProjectSearchResult{
			ProjectID:   project.ID,
			ProjectName: project.Name,
			Path:        path,
			Line:        item.Data.LineNumber,
			Text:        strings.TrimSpace(item.Data.Lines.Text),
		})
		if len(results) >= limit {
			break
		}
	}
	return results, nil
}

var errStopBotSearch = errors.New("stop bot project search")

func searchBotProjectsFallback(ctx context.Context, projects []botProjectEntry, roots []string, query string, limit int) ([]botProjectSearchResult, error) {
	queryLower := strings.ToLower(query)
	var results []botProjectSearchResult
	for _, root := range roots {
		if err := ctx.Err(); err != nil {
			return results, err
		}
		walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return nil
			}
			if err := ctx.Err(); err != nil {
				return err
			}
			if d.IsDir() {
				if shouldSkipBotSearchDir(d.Name()) && path != root {
					return filepath.SkipDir
				}
				return nil
			}
			info, err := d.Info()
			if err != nil || info.Size() > 1024*1024 {
				return nil
			}
			file, err := os.Open(path)
			if err != nil {
				return nil
			}
			scanner := bufio.NewScanner(file)
			scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
			line := 0
			for scanner.Scan() {
				if err := ctx.Err(); err != nil {
					_ = file.Close()
					return err
				}
				line++
				text := scanner.Text()
				if strings.Contains(strings.ToLower(text), queryLower) {
					project := botProjectForPath(projects, path)
					results = append(results, botProjectSearchResult{
						ProjectID:   project.ID,
						ProjectName: project.Name,
						Path:        canonicalBotPath(path),
						Line:        line,
						Text:        strings.TrimSpace(text),
					})
					if len(results) >= limit {
						_ = file.Close()
						return errStopBotSearch
					}
				}
			}
			_ = file.Close()
			return nil
		})
		if errors.Is(walkErr, errStopBotSearch) {
			break
		}
		if walkErr != nil {
			return results, walkErr
		}
		if len(results) >= limit {
			break
		}
	}
	return results, nil
}

func shouldSkipBotSearchDir(name string) bool {
	switch name {
	case ".git", "node_modules", "dist", "build", "vendor", ".next", ".cache":
		return true
	default:
		return false
	}
}

func botProjectForPath(projects []botProjectEntry, path string) botProjectEntry {
	path = canonicalBotPath(path)
	var best botProjectEntry
	for _, project := range projects {
		root := canonicalBotPath(project.Root)
		if root == "" {
			continue
		}
		if path == root || strings.HasPrefix(path, root+string(os.PathSeparator)) {
			if len(root) > len(best.Root) {
				best = project
			}
		}
	}
	return best
}

func canonicalBotPath(path string) string {
	path = strings.TrimSpace(path)
	if path == "" {
		return ""
	}
	if abs, err := filepath.Abs(path); err == nil {
		path = abs
	}
	return filepath.Clean(path)
}

func botProjectName(root string) string {
	root = strings.TrimRight(canonicalBotPath(root), string(os.PathSeparator))
	if root == "" {
		return ""
	}
	name := filepath.Base(root)
	if name == "." || name == string(os.PathSeparator) {
		return root
	}
	return name
}

func displayBotPath(path string) string {
	path = canonicalBotPath(path)
	home, err := os.UserHomeDir()
	if err == nil {
		home = canonicalBotPath(home)
		if home != "" && (path == home || strings.HasPrefix(path, home+string(os.PathSeparator))) {
			return "~" + strings.TrimPrefix(path, home)
		}
	}
	return path
}

func singleLineBotText(text string, limit int) string {
	text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
	if limit <= 0 {
		return text
	}
	runes := []rune(text)
	if len(runes) <= limit {
		return text
	}
	return string(runes[:limit-1]) + "…"
}

func shortBotID(value string) string {
	value = strings.TrimSpace(value)
	if len(value) <= 8 {
		return value
	}
	return value[:8]
}

func firstNonEmptyString(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}
