package botruntime

import (
	"fmt"
	"log/slog"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"reasonix/internal/bot"
	"reasonix/internal/bot/dingtalk"
	"reasonix/internal/bot/feishu"
	"reasonix/internal/bot/qq"
	"reasonix/internal/bot/weixin"
	"reasonix/internal/config"
)

// EnabledPlatforms resolves the requested channel list against the saved config.
// "lark" is a domain alias for the Feishu adapter platform.
func EnabledPlatforms(cfg *config.Config, channels []string) (map[bot.Platform]bool, []string) {
	enabled := make(map[bot.Platform]bool)
	var warnings []string
	if len(channels) > 0 {
		for _, ch := range channels {
			ch = strings.TrimSpace(ch)
			switch bot.Platform(ch) {
			case bot.PlatformQQ:
				enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
			case bot.PlatformFeishu:
				enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
			case bot.PlatformWeixin:
				enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
			case bot.PlatformDingtalk:
				enabled[bot.PlatformDingtalk] = PlatformConfigured(cfg, bot.PlatformDingtalk)
			default:
				if strings.EqualFold(ch, "lark") {
					enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
				} else if ch != "" {
					warnings = append(warnings, ch)
				}
			}
		}
		return enabled, warnings
	}
	enabled[bot.PlatformQQ] = PlatformConfigured(cfg, bot.PlatformQQ)
	enabled[bot.PlatformFeishu] = PlatformConfigured(cfg, bot.PlatformFeishu)
	enabled[bot.PlatformWeixin] = PlatformConfigured(cfg, bot.PlatformWeixin)
	enabled[bot.PlatformDingtalk] = PlatformConfigured(cfg, bot.PlatformDingtalk)
	return enabled, warnings
}

// RequestedFeishuDomains returns the Feishu-family domains the caller explicitly
// named ("feishu"/"lark"), or nil when neither was requested (no restriction).
func RequestedFeishuDomains(channels []string) map[string]bool {
	domains := make(map[string]bool)
	for _, ch := range channels {
		switch {
		case strings.EqualFold(strings.TrimSpace(ch), string(bot.PlatformFeishu)):
			domains["feishu"] = true
		case strings.EqualFold(strings.TrimSpace(ch), "lark"):
			domains["lark"] = true
		}
	}
	if len(domains) == 0 {
		return nil
	}
	return domains
}

func feishuDomainKey(domain string) string {
	if strings.EqualFold(strings.TrimSpace(domain), "lark") {
		return "lark"
	}
	return "feishu"
}

func HasEnabledPlatform(enabled map[bot.Platform]bool) bool {
	for _, value := range enabled {
		if value {
			return true
		}
	}
	return false
}

func PlatformConfigured(cfg *config.Config, platform bot.Platform) bool {
	if cfg == nil {
		return false
	}
	switch platform {
	case bot.PlatformQQ:
		if cfg.Bot.QQ.Enabled {
			return true
		}
	case bot.PlatformFeishu:
		if cfg.Bot.Feishu.Enabled {
			return true
		}
	case bot.PlatformWeixin:
		if cfg.Bot.Weixin.Enabled {
			return true
		}
	case bot.PlatformDingtalk:
		if cfg.Bot.Dingtalk.Enabled {
			return true
		}
	}
	for _, conn := range cfg.Bot.Connections {
		if conn.Enabled && bot.Platform(strings.TrimSpace(conn.Provider)) == platform {
			return true
		}
	}
	return false
}

func ChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[bot.Platform]bot.ChannelConfig {
	if len(connections) == 0 {
		return nil
	}
	out := make(map[bot.Platform]bot.ChannelConfig)
	for _, conn := range connections {
		if !conn.Enabled {
			continue
		}
		plat := bot.Platform(strings.TrimSpace(conn.Provider))
		switch plat {
		case bot.PlatformQQ, bot.PlatformFeishu, bot.PlatformWeixin, bot.PlatformDingtalk:
		default:
			continue
		}
		channel := out[plat]
		if includeModel {
			channel.Model = strings.TrimSpace(conn.Model)
		}
		if includeWorkspaceRoot {
			channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
		}
		if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
			channel.ToolApprovalMode = value
		}
		if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" {
			out[plat] = channel
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func ConnectionChannelConfigs(connections []config.BotConnectionConfig, includeModel bool, includeWorkspaceRoot bool) map[string]bot.ChannelConfig {
	if len(connections) == 0 {
		return nil
	}
	out := make(map[string]bot.ChannelConfig)
	for _, conn := range connections {
		if !conn.Enabled {
			continue
		}
		id := ConnectionRuntimeID(conn)
		if id == "" {
			continue
		}
		var channel bot.ChannelConfig
		if includeModel {
			channel.Model = strings.TrimSpace(conn.Model)
		}
		if includeWorkspaceRoot {
			channel.WorkspaceRoot = strings.TrimSpace(conn.WorkspaceRoot)
			channel.SessionMappings = SessionMappings(conn.SessionMappings)
		}
		if value := normalizeToolApprovalMode(conn.ToolApprovalMode); value != "" {
			channel.ToolApprovalMode = value
		}
		if channel.Model != "" || channel.WorkspaceRoot != "" || channel.ToolApprovalMode != "" || len(channel.SessionMappings) > 0 {
			out[id] = channel
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func ConnectionAccessConfigs(cfg *config.Config) map[string]bot.AccessConfig {
	if cfg == nil {
		return nil
	}
	out := make(map[string]bot.AccessConfig)
	if BotAccessActive(cfg.Bot.QQ.Access) {
		out[string(bot.PlatformQQ)] = botAccessConfig(cfg.Bot.QQ.Access)
	}
	if BotAccessActive(cfg.Bot.Dingtalk.Access) {
		out[string(bot.PlatformDingtalk)] = botAccessConfig(cfg.Bot.Dingtalk.Access)
	}
	for _, conn := range cfg.Bot.Connections {
		if !conn.Enabled {
			continue
		}
		id := ConnectionRuntimeID(conn)
		if id == "" || !BotAccessActive(conn.Access) {
			continue
		}
		out[id] = botAccessConfig(conn.Access)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func BotAccessActive(access config.BotAccessConfig) bool {
	return access.Enabled ||
		access.AllowAll ||
		access.PairingEnabled ||
		len(access.Users) > 0 ||
		len(access.Groups) > 0 ||
		len(access.Approvers) > 0 ||
		len(access.Admins) > 0
}

func botAccessConfig(access config.BotAccessConfig) bot.AccessConfig {
	return bot.AccessConfig{
		Enabled:        access.Enabled,
		AllowAll:       access.AllowAll,
		PairingEnabled: access.PairingEnabled,
		Users:          trimStringSlice(access.Users),
		Groups:         trimStringSlice(access.Groups),
		Approvers:      trimStringSlice(access.Approvers),
		Admins:         trimStringSlice(access.Admins),
	}
}

func trimStringSlice(values []string) []string {
	if len(values) == 0 {
		return nil
	}
	out := make([]string, 0, len(values))
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value != "" {
			out = append(out, value)
		}
	}
	return out
}

// SessionMappings 把配置层会话绑定转换为 gateway 运行时映射（connection 与
// legacy 直配渠道共用）。
func SessionMappings(mappings []config.BotConnectionSessionMapping) []bot.SessionMapping {
	if len(mappings) == 0 {
		return nil
	}
	out := make([]bot.SessionMapping, 0, len(mappings))
	for _, mapping := range mappings {
		out = append(out, bot.SessionMapping{
			RemoteID:      strings.TrimSpace(mapping.RemoteID),
			SessionID:     strings.TrimSpace(mapping.SessionID),
			SessionSource: strings.TrimSpace(mapping.SessionSource),
			ChatType:      strings.TrimSpace(mapping.ChatType),
			UserID:        strings.TrimSpace(mapping.UserID),
			ThreadID:      strings.TrimSpace(mapping.ThreadID),
			Scope:         strings.TrimSpace(mapping.Scope),
			WorkspaceRoot: strings.TrimSpace(mapping.WorkspaceRoot),
			UpdatedAt:     strings.TrimSpace(mapping.UpdatedAt),
		})
	}
	return out
}

func RouteConfigs(routes []config.BotRouteConfig, includeModel bool, includeWorkspaceRoot bool) []bot.RouteConfig {
	if len(routes) == 0 {
		return nil
	}
	out := make([]bot.RouteConfig, 0, len(routes))
	for _, route := range routes {
		var channel bot.ChannelConfig
		if includeModel {
			channel.Model = strings.TrimSpace(route.Model)
		}
		if includeWorkspaceRoot {
			channel.WorkspaceRoot = strings.TrimSpace(route.WorkspaceRoot)
		}
		if value := normalizeToolApprovalMode(route.ToolApprovalMode); value != "" {
			channel.ToolApprovalMode = value
		}
		if channel.Model == "" && channel.WorkspaceRoot == "" && channel.ToolApprovalMode == "" {
			continue
		}
		out = append(out, bot.RouteConfig{
			ConnectionID: strings.TrimSpace(route.ConnectionID),
			Platform:     bot.Platform(strings.TrimSpace(route.Platform)),
			ChatType:     bot.ChatType(strings.TrimSpace(route.ChatType)),
			ChatID:       strings.TrimSpace(route.ChatID),
			UserID:       strings.TrimSpace(route.UserID),
			ThreadID:     strings.TrimSpace(route.ThreadID),
			Channel:      channel,
		})
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func normalizeToolApprovalMode(mode string) string {
	switch strings.ToLower(strings.TrimSpace(mode)) {
	case "ask":
		return "ask"
	case "auto":
		return "auto"
	case "yolo", "full", "full-access", "bypass":
		return "yolo"
	default:
		return ""
	}
}

// MergeLegacyDingtalkChannel merges the legacy [bot.dingtalk] runtime options
// (model / tool_approval_mode / workspace_root) into the per-platform and
// per-connection channel maps so a directly-configured DingTalk bot (no
// [[bot.connections]] record) honors them. The desktop does the same via
// desktopBotChannelsWithLegacyDingtalk; the CLI bot mode needs the equivalent.
func MergeLegacyDingtalkChannel(dt config.DingtalkBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
	channel := bot.ChannelConfig{
		Model:            strings.TrimSpace(dt.Model),
		ToolApprovalMode: normalizeToolApprovalMode(dt.ToolApprovalMode),
		WorkspaceRoot:    strings.TrimSpace(dt.WorkspaceRoot),
		SessionMappings:  SessionMappings(dt.SessionMappings),
	}
	if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" && len(channel.SessionMappings) == 0 {
		return channels, connectionChannels
	}
	if channels == nil {
		channels = make(map[bot.Platform]bot.ChannelConfig)
	}
	if _, ok := channels[bot.PlatformDingtalk]; !ok {
		channels[bot.PlatformDingtalk] = channel
	}
	if connectionChannels == nil {
		connectionChannels = make(map[string]bot.ChannelConfig)
	}
	if _, ok := connectionChannels[string(bot.PlatformDingtalk)]; !ok {
		connectionChannels[string(bot.PlatformDingtalk)] = channel
	}
	return channels, connectionChannels
}

func AdapterBindings(cfg *config.Config, enabled map[bot.Platform]bool, feishuDomains map[string]bool, logger *slog.Logger) []bot.AdapterBinding {
	if cfg == nil {
		return nil
	}
	var bindings []bot.AdapterBinding
	hasConnection := make(map[bot.Platform]bool)
	for _, conn := range cfg.Bot.Connections {
		if !conn.Enabled {
			continue
		}
		platform := bot.Platform(strings.TrimSpace(conn.Provider))
		if !enabled[platform] {
			continue
		}
		id := ConnectionRuntimeID(conn)
		switch platform {
		case bot.PlatformQQ:
			qqCfg := cfg.Bot.QQ
			qqCfg.Enabled = true
			qqCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), qqCfg.AppID)
			qqCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), qqCfg.AppSecretEnv)
			bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: qq.New(qqCfg, logger)})
			hasConnection[platform] = true
		case bot.PlatformFeishu:
			feishuCfg := cfg.Bot.Feishu
			feishuCfg.Enabled = true
			feishuCfg.Domain = firstNonEmptyString(strings.TrimSpace(conn.Domain), feishuCfg.Domain)
			if feishuDomains != nil && !feishuDomains[feishuDomainKey(feishuCfg.Domain)] {
				continue
			}
			feishuCfg.AppID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), feishuCfg.AppID)
			feishuCfg.AppSecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), feishuCfg.AppSecretEnv)
			bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: feishuCfg.Domain, Platform: platform, Adapter: feishu.New(feishuCfg, logger)})
			hasConnection[platform] = true
		case bot.PlatformWeixin:
			weixinCfg := cfg.Bot.Weixin
			weixinCfg.Enabled = true
			weixinCfg.AccountID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AccountID), weixinCfg.AccountID)
			weixinCfg.TokenEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.TokenEnv), weixinCfg.TokenEnv)
			bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: weixin.New(weixinCfg, logger)})
			hasConnection[platform] = true
		case bot.PlatformDingtalk:
			dingtalkCfg := cfg.Bot.Dingtalk
			dingtalkCfg.Enabled = true
			dingtalkCfg.ClientID = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppID), dingtalkCfg.ClientID)
			dingtalkCfg.SecretEnv = firstNonEmptyString(strings.TrimSpace(conn.Credential.AppSecretEnv), dingtalkCfg.SecretEnv)
			bindings = append(bindings, bot.AdapterBinding{ID: id, Domain: strings.TrimSpace(conn.Domain), Platform: platform, Adapter: dingtalk.New(dingtalkCfg, logger)})
			hasConnection[platform] = true
		}
	}
	if enabled[bot.PlatformQQ] && !hasConnection[bot.PlatformQQ] {
		bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformQQ), Platform: bot.PlatformQQ, Adapter: qq.New(cfg.Bot.QQ, logger)})
	}
	if enabled[bot.PlatformFeishu] && !hasConnection[bot.PlatformFeishu] {
		if feishuDomains == nil || feishuDomains[feishuDomainKey(cfg.Bot.Feishu.Domain)] {
			bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformFeishu), Domain: cfg.Bot.Feishu.Domain, Platform: bot.PlatformFeishu, Adapter: feishu.New(cfg.Bot.Feishu, logger)})
		}
	}
	if enabled[bot.PlatformWeixin] && !hasConnection[bot.PlatformWeixin] {
		bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformWeixin), Domain: "weixin", Platform: bot.PlatformWeixin, Adapter: weixin.New(cfg.Bot.Weixin, logger)})
	}
	if enabled[bot.PlatformDingtalk] && !hasConnection[bot.PlatformDingtalk] {
		bindings = append(bindings, bot.AdapterBinding{ID: string(bot.PlatformDingtalk), Domain: "dingtalk", Platform: bot.PlatformDingtalk, Adapter: dingtalk.New(cfg.Bot.Dingtalk, logger)})
	}
	return bindings
}

func ConnectionRuntimeID(conn config.BotConnectionConfig) string {
	if id := strings.TrimSpace(conn.ID); id != "" {
		return id
	}
	provider := strings.TrimSpace(conn.Provider)
	domain := strings.TrimSpace(conn.Domain)
	if provider == "" {
		return ""
	}
	if domain == "" {
		return provider
	}
	return provider + "-" + domain
}

func ModelName(cfg *config.Config, override string) string {
	if strings.TrimSpace(override) != "" {
		return strings.TrimSpace(override)
	}
	if cfg == nil {
		return ""
	}
	if strings.TrimSpace(cfg.Bot.Model) != "" {
		return strings.TrimSpace(cfg.Bot.Model)
	}
	return strings.TrimSpace(cfg.DefaultModel)
}

// ModelResolver 构造 /model 切换前的模型预校验器：模型必须可解析
// （provider/model 存在）且该 provider 已配置 API key，否则拒绝切换并
// 保留当前会话 controller（失败原子性，见 bot.GatewayConfig.ModelResolver）。
func ModelResolver(cfg *config.Config) func(string) error {
	return func(ref string) error {
		entry, ok := cfg.ResolveModel(strings.TrimSpace(ref))
		if !ok {
			return fmt.Errorf("未配置该模型（provider/model 不存在）")
		}
		if !entry.Configured() {
			return fmt.Errorf("%s 未配置 API key，无法使用", entry.Name)
		}
		return nil
	}
}

func AllowlistUserCount(a config.BotAllowlist) int {
	return len(a.QQUsers) + len(a.FeishuUsers) + len(a.WeixinUsers) + len(a.DingtalkUsers) +
		len(a.QQApprovers) + len(a.FeishuApprovers) + len(a.WeixinApprovers) + len(a.DingtalkApprovers) +
		len(a.QQAdmins) + len(a.FeishuAdmins) + len(a.WeixinAdmins) + len(a.DingtalkAdmins)
}

func BotAccessUserCount(access config.BotAccessConfig) int {
	return len(access.Users) + len(access.Groups) + len(access.Approvers) + len(access.Admins)
}

func BotConfigHasAccessControl(bc config.BotConfig) bool {
	if bc.Allowlist.AllowAll || bc.Pairing.Enabled || (bc.Allowlist.Enabled && AllowlistUserCount(bc.Allowlist) > 0) {
		return true
	}
	if BotAccessActive(bc.QQ.Access) || BotAccessActive(bc.Dingtalk.Access) {
		return true
	}
	for _, conn := range bc.Connections {
		if conn.Enabled && BotAccessActive(conn.Access) {
			return true
		}
	}
	return false
}

func NewRemoteRememberer(logger *slog.Logger) func(bot.InboundMessage) {
	var mu sync.Mutex
	seen := make(map[string]bool)
	return func(msg bot.InboundMessage) {
		remoteID := strings.TrimSpace(msg.ChatID)
		if remoteID == "" {
			return
		}
		key := strings.Join([]string{
			string(msg.Platform),
			strings.TrimSpace(msg.ConnectionID),
			strings.TrimSpace(msg.Domain),
			string(msg.ChatType),
			remoteID,
			strings.TrimSpace(msg.UserID),
		}, "\x00")
		mu.Lock()
		if seen[key] {
			mu.Unlock()
			return
		}
		seen[key] = true
		mu.Unlock()

		if err := RememberInbound(msg); err != nil && logger != nil {
			logger.Warn("remember bot remote failed", "platform", msg.Platform, "err", err)
		}
	}
}

func NewSessionRememberer(logger *slog.Logger) func(bot.InboundMessage, string) error {
	return NewSessionRemembererWithWorkspace(logger, "")
}

func NewSessionRemembererWithWorkspace(logger *slog.Logger, workspaceRoot string) func(bot.InboundMessage, string) error {
	return func(msg bot.InboundMessage, sessionID string) error {
		if err := RememberInboundSessionWorkspace(msg, sessionID, workspaceRoot); err != nil {
			if logger != nil {
				logger.Warn("remember bot session failed", "platform", msg.Platform, "err", err)
			}
			return err
		}
		return nil
	}
}

func RememberInbound(msg bot.InboundMessage) error {
	return rememberInbound(msg, "", "")
}

func RememberInboundSession(msg bot.InboundMessage, sessionID string) error {
	return RememberInboundSessionWorkspace(msg, sessionID, "")
}

func RememberInboundSessionWorkspace(msg bot.InboundMessage, sessionID string, workspaceRoot string) error {
	return rememberInbound(msg, strings.TrimSpace(sessionID), strings.TrimSpace(workspaceRoot))
}

func ForgetAutoSessionMappingsForPath(sessionPath string) error {
	target := normalizedBotSessionPath(sessionPath)
	if target == "" {
		return nil
	}
	userPath := config.UserConfigPath()
	if strings.TrimSpace(userPath) == "" {
		return nil
	}
	unlock := config.LockUserConfigEdits()
	defer unlock()

	cfg := config.LoadForEdit(userPath)
	now := time.Now().UTC().Format(time.RFC3339)
	changed := false
	for i := range cfg.Bot.Connections {
		conn := &cfg.Bot.Connections[i]
		next := conn.SessionMappings[:0]
		removed := false
		for _, mapping := range conn.SessionMappings {
			if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target {
				removed = true
				continue
			}
			next = append(next, mapping)
		}
		if !removed {
			continue
		}
		conn.SessionMappings = next
		conn.UpdatedAt = now
		changed = true
	}
	// legacy 直配渠道的 auto mapping 同样清理，避免残留旧会话绑定。
	ding := &cfg.Bot.Dingtalk
	dingNext := ding.SessionMappings[:0]
	dingRemoved := false
	for _, mapping := range ding.SessionMappings {
		if strings.TrimSpace(mapping.SessionSource) == "auto" && normalizedBotSessionPath(mapping.SessionID) == target {
			dingRemoved = true
			continue
		}
		dingNext = append(dingNext, mapping)
	}
	if dingRemoved {
		ding.SessionMappings = dingNext
		changed = true
	}
	if !changed {
		return nil
	}
	return cfg.SaveTo(userPath)
}

func rememberInbound(msg bot.InboundMessage, sessionID string, actualWorkspaceRoot string) error {
	userPath := config.UserConfigPath()
	platform := msg.Platform
	remoteID := strings.TrimSpace(msg.ChatID)
	if userPath == "" || remoteID == "" {
		return nil
	}
	unlock := config.LockUserConfigEdits()
	defer unlock()

	cfg := config.LoadForEdit(userPath)
	now := time.Now().UTC().Format(time.RFC3339)
	changed := false
	for i := range cfg.Bot.Connections {
		conn := &cfg.Bot.Connections[i]
		if strings.TrimSpace(conn.Provider) != string(platform) || !conn.Enabled || !connectionMatchesInbound(*conn, msg) {
			continue
		}
		if rememberConnMappings(&conn.SessionMappings, conn.WorkspaceRoot, msg, remoteID, sessionID, actualWorkspaceRoot, now) {
			conn.UpdatedAt = now
			changed = true
		}
	}
	// legacy 直配 [bot.dingtalk] 没有 connection 记录：/new 旋转后的会话
	// 路径持久化到 Dingtalk.SessionMappings，重启后仍能恢复（#9116 review）。
	if platform == bot.PlatformDingtalk && cfg.Bot.Dingtalk.Enabled && !botHasDingtalkConnection(cfg.Bot.Connections) {
		if rememberConnMappings(&cfg.Bot.Dingtalk.SessionMappings, cfg.Bot.Dingtalk.WorkspaceRoot, msg, remoteID, sessionID, actualWorkspaceRoot, now) {
			changed = true
		}
	}
	if rememberAllowlist(&cfg.Bot.Allowlist, platform, msg.UserID, remoteID, msg.ChatType) {
		changed = true
	}
	if !changed {
		return nil
	}
	return cfg.SaveTo(userPath)
}

// botHasDingtalkConnection 判断是否存在匹配的钉钉 connection 记录。
func botHasDingtalkConnection(conns []config.BotConnectionConfig) bool {
	for _, conn := range conns {
		if conn.Enabled && strings.TrimSpace(conn.Provider) == string(bot.PlatformDingtalk) {
			return true
		}
	}
	return false
}

// rememberConnMappings 把会话绑定写入 mappings 切片（connection 与 legacy
// 直配渠道共用）。返回是否有变更。
func rememberConnMappings(mappings *[]config.BotConnectionSessionMapping, channelWorkspaceRoot string, msg bot.InboundMessage, remoteID, sessionID, actualWorkspaceRoot, now string) bool {
	mappingIndex := -1
	for j := range *mappings {
		if botSessionMappingMatches((*mappings)[j], msg) {
			mappingIndex = j
			break
		}
	}
	if mappingIndex >= 0 {
		if sessionID == "" {
			return false
		}
		mapping := &(*mappings)[mappingIndex]
		current := strings.TrimSpace(mapping.SessionID)
		if current == sessionID || botSessionMappingHasExplicitTarget(*mapping) {
			return false
		}
		mapping.SessionID = sessionID
		mapping.SessionSource = "auto"
		mapping.UpdatedAt = now
		return true
	}
	scope := "global"
	workspaceRoot := ""
	if strings.TrimSpace(channelWorkspaceRoot) != "" {
		scope = "project"
		workspaceRoot = strings.TrimSpace(channelWorkspaceRoot)
	} else if actualWorkspaceRoot != "" {
		scope = "project"
		workspaceRoot = actualWorkspaceRoot
	}
	chatType, userID, threadID := botSessionMappingIdentity(msg)
	*mappings = append(*mappings, config.BotConnectionSessionMapping{
		RemoteID:      remoteID,
		SessionID:     sessionID,
		SessionSource: botSessionSource(sessionID),
		ChatType:      chatType,
		UserID:        userID,
		ThreadID:      threadID,
		Scope:         scope,
		WorkspaceRoot: workspaceRoot,
		UpdatedAt:     now,
	})
	return true
}

func botSessionMappingMatches(mapping config.BotConnectionSessionMapping, msg bot.InboundMessage) bool {
	if strings.TrimSpace(mapping.RemoteID) != strings.TrimSpace(msg.ChatID) {
		return false
	}
	chatType, userID, threadID := botSessionMappingIdentity(msg)
	mappingChatType := strings.TrimSpace(mapping.ChatType)
	if mappingChatType == "" {
		return chatType == ""
	}
	if mappingChatType != chatType {
		return false
	}
	if strings.TrimSpace(mapping.UserID) != userID {
		return false
	}
	return strings.TrimSpace(mapping.ThreadID) == threadID
}

func botSessionMappingIdentity(msg bot.InboundMessage) (chatType string, userID string, threadID string) {
	switch msg.ChatType {
	case bot.ChatGroup, bot.ChatGuild:
		chatType = string(msg.ChatType)
		userID = strings.TrimSpace(msg.UserID)
	case bot.ChatThread:
		chatType = string(msg.ChatType)
		threadID = strings.TrimSpace(msg.ThreadID)
		if threadID == "" {
			threadID = strings.TrimSpace(msg.ChatID)
		}
	}
	return chatType, userID, threadID
}

func botSessionMappingHasExplicitTarget(mapping config.BotConnectionSessionMapping) bool {
	sessionID := strings.TrimSpace(mapping.SessionID)
	if sessionID == "" || strings.TrimSpace(mapping.SessionSource) == "auto" {
		return false
	}
	return true
}

func botSessionSource(sessionID string) string {
	if strings.TrimSpace(sessionID) == "" {
		return ""
	}
	return "auto"
}

func normalizedBotSessionPath(sessionID string) string {
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return ""
	}
	if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
		sessionID = strings.TrimSpace(sessionID[5:])
	}
	if sessionID == "" {
		return ""
	}
	if !(strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~")) {
		return ""
	}
	return filepath.Clean(sessionID)
}

func connectionMatchesInbound(conn config.BotConnectionConfig, msg bot.InboundMessage) bool {
	if msg.ConnectionID != "" {
		return ConnectionRuntimeID(conn) == strings.TrimSpace(msg.ConnectionID)
	}
	if msg.Domain != "" && strings.TrimSpace(conn.Domain) != "" {
		return strings.EqualFold(strings.TrimSpace(conn.Domain), strings.TrimSpace(msg.Domain))
	}
	return true
}

func rememberAllowlist(allowlist *config.BotAllowlist, platform bot.Platform, userID string, chatID string, chatType bot.ChatType) bool {
	if allowlist == nil {
		return false
	}
	changed := false
	userID = strings.TrimSpace(userID)
	if userID != "" {
		switch platform {
		case bot.PlatformQQ:
			allowlist.QQUsers, changed = appendUniqueString(allowlist.QQUsers, userID)
		case bot.PlatformFeishu:
			allowlist.FeishuUsers, changed = appendUniqueString(allowlist.FeishuUsers, userID)
		case bot.PlatformWeixin:
			allowlist.WeixinUsers, changed = appendUniqueString(allowlist.WeixinUsers, userID)
		case bot.PlatformDingtalk:
			allowlist.DingtalkUsers, changed = appendUniqueString(allowlist.DingtalkUsers, userID)
		}
	}
	if !chatUsesGroupAllowlist(chatType) {
		return changed
	}
	groupID := strings.TrimSpace(chatID)
	if groupID == "" {
		return changed
	}
	groupChanged := false
	switch platform {
	case bot.PlatformQQ:
		allowlist.QQGroups, groupChanged = appendUniqueString(allowlist.QQGroups, groupID)
	case bot.PlatformFeishu:
		allowlist.FeishuGroups, groupChanged = appendUniqueString(allowlist.FeishuGroups, groupID)
	case bot.PlatformWeixin:
		allowlist.WeixinGroups, groupChanged = appendUniqueString(allowlist.WeixinGroups, groupID)
	case bot.PlatformDingtalk:
		allowlist.DingtalkGroups, groupChanged = appendUniqueString(allowlist.DingtalkGroups, groupID)
	}
	return changed || groupChanged
}

func appendUniqueString(values []string, next string) ([]string, bool) {
	next = strings.TrimSpace(next)
	if next == "" {
		return values, false
	}
	for _, value := range values {
		if strings.TrimSpace(value) == next {
			return values, false
		}
	}
	return append(values, next), true
}

func chatUsesGroupAllowlist(chatType bot.ChatType) bool {
	switch chatType {
	case bot.ChatGroup, bot.ChatGuild, bot.ChatThread:
		return true
	default:
		return false
	}
}

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