package cmd

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"strings"

	"github.com/google/uuid"

	"github.com/nextlevelbuilder/goclaw/internal/agent"
	"github.com/nextlevelbuilder/goclaw/internal/bus"
	"github.com/nextlevelbuilder/goclaw/internal/channels"
	"github.com/nextlevelbuilder/goclaw/internal/config"
	"github.com/nextlevelbuilder/goclaw/internal/i18n"
	"github.com/nextlevelbuilder/goclaw/internal/scheduler"
	"github.com/nextlevelbuilder/goclaw/internal/sessions"
	"github.com/nextlevelbuilder/goclaw/internal/store"
	"github.com/nextlevelbuilder/goclaw/internal/tools"
)

// processNormalMessage handles routing, scheduling, and response delivery for a single
// (possibly merged) inbound message. Called directly by the debouncer's flush callback.
func processNormalMessage(
	ctx context.Context,
	msg bus.InboundMessage,
	agents *agent.Router,
	cfg *config.Config,
	sched *scheduler.Scheduler,
	channelMgr *channels.Manager,
	teamStore store.TeamStore,
	quotaChecker *channels.QuotaChecker,
	sessStore store.SessionStore,
	agentStore store.AgentStore,
	contactCollector *store.ContactCollector,
	postTurn tools.PostTurnProcessor,
	msgBus *bus.MessageBus,
) {
	// Determine target agent via bindings or explicit AgentID
	agentID := msg.AgentID
	if agentID == "" {
		agentID = resolveAgentRoute(cfg, msg.Channel, msg.ChatID, msg.PeerKind)
	}

	agentLoop, err := agents.Get(agentID)
	if err != nil {
		slog.Warn("inbound: agent not found", "agent", agentID, "channel", msg.Channel)
		return
	}

	// Build session key based on scope config (matching TS buildAgentPeerSessionKey).
	peerKind := msg.PeerKind
	if peerKind == "" {
		peerKind = string(sessions.PeerDirect) // default to DM
	}
	sessionKey := sessions.BuildScopedSessionKey(agentID, msg.Channel, sessions.PeerKind(peerKind), msg.ChatID, cfg.Sessions.Scope, cfg.Sessions.DmScope, cfg.Sessions.MainKey)

	// Forum topic: override session key to isolate per-topic history.
	// TS ref: buildTelegramGroupPeerId() in src/telegram/bot/helpers.ts
	if msg.Metadata["is_forum"] == "true" && peerKind == string(sessions.PeerGroup) {
		var topicID int
		fmt.Sscanf(msg.Metadata["message_thread_id"], "%d", &topicID)
		if topicID > 0 {
			sessionKey = sessions.BuildGroupTopicSessionKey(agentID, msg.Channel, msg.ChatID, topicID)
		}
	}

	// DM thread: override session key to isolate per-thread history in private chats.
	if msg.Metadata["dm_thread_id"] != "" && peerKind == string(sessions.PeerDirect) {
		var threadID int
		fmt.Sscanf(msg.Metadata["dm_thread_id"], "%d", &threadID)
		if threadID > 0 {
			sessionKey = sessions.BuildDMThreadSessionKey(agentID, msg.Channel, msg.ChatID, threadID)
		}
	}

	// Group-scoped UserID: context files, memory, traces, and seeding scope.
	// - Discord guilds: "guild:{guildID}:user:{senderID}" — per-user per-server,
	//   shared across all channels within the same server. Session key stays per-channel.
	// - Other platforms: "group:{channel}:{chatID}" — shared by all users in the chat.
	// Individual senderID is preserved in InboundMessage for pairing/dedup/mention gate.
	userID := msg.UserID
	if peerKind == string(sessions.PeerGroup) && msg.ChatID != "" {
		if guildID := msg.Metadata["guild_id"]; guildID != "" && msg.SenderID != "" {
			// Discord guild: per-user scope so each member has own profile
			// across all channels in the same server.
			userID = fmt.Sprintf("guild:%s:user:%s", guildID, msg.SenderID)
		} else {
			groupID := msg.ChatID
			userID = fmt.Sprintf("group:%s:%s", msg.Channel, groupID)
		}
	}

	// Persist friendly names from channel metadata into session + user profile.
	sessionMeta := extractSessionMetadata(msg, peerKind)
	if len(sessionMeta) > 0 {
		sessStore.SetSessionMetadata(sessionKey, sessionMeta)
		if agentStore != nil {
			if agentUUID, err := uuid.Parse(agentID); err == nil && agentUUID != uuid.Nil {
				_ = agentStore.UpdateUserProfileMetadata(ctx, agentUUID, userID, sessionMeta)
			}
		}
	}

	// Auto-collect channel contacts for the contact selector.
	if contactCollector != nil && msg.SenderID != "" {
		senderNumericID := msg.SenderID
		if idx := strings.IndexByte(senderNumericID, '|'); idx > 0 {
			senderNumericID = senderNumericID[:idx]
		}
		channelType := channelMgr.ChannelTypeForName(msg.Channel)
		if channelType == "" {
			channelType = msg.Channel // fallback to instance name
		}
		displayName := sessionMeta["display_name"]
		username := sessionMeta["username"]
		contactCollector.EnsureContact(ctx, channelType, msg.Channel, senderNumericID, userID, displayName, username, peerKind)
	}

	// --- Quota check ---
	if quotaChecker != nil {
		qResult := quotaChecker.Check(ctx, userID, msg.Channel, agentLoop.ProviderName())
		if !qResult.Allowed {
			slog.Warn("security.quota_exceeded",
				"user_id", userID,
				"channel", msg.Channel,
				"window", qResult.Window,
				"used", qResult.Used,
				"limit", qResult.Limit,
			)
			msgBus.PublishOutbound(bus.OutboundMessage{
				Channel:  msg.Channel,
				ChatID:   msg.ChatID,
				Content:  formatQuotaExceeded(qResult),
				Metadata: msg.Metadata,
			})
			return
		}
		quotaChecker.Increment(userID)
	}

	// Auto-clear followup reminders when user sends a message on a real channel.
	// Fire-and-forget: don't block message processing.
	if teamStore != nil && msg.Channel != tools.ChannelSystem && msg.Channel != tools.ChannelTeammate && msg.Channel != tools.ChannelDashboard {
		go func(ch, cid string) {
			if n, err := teamStore.ClearFollowupByScope(ctx, ch, cid); err != nil {
				slog.Warn("auto-clear followup failed", "channel", ch, "chat_id", cid, "error", err)
			} else if n > 0 {
				slog.Info("auto-clear followup: cleared", "channel", ch, "chat_id", cid, "count", n)
			}
		}(msg.Channel, msg.ChatID)
	}

	slog.Info("inbound: scheduling message (main lane)",
		"channel", msg.Channel,
		"chat_id", msg.ChatID,
		"peer_kind", peerKind,
		"agent", agentID,
		"session", sessionKey,
		"user_id", userID,
	)

	// Enable streaming when the channel supports it (so agent emits chunk events).
	// The channel decides per chat type via separate dm_stream / group_stream flags.
	isGroup := peerKind == string(sessions.PeerGroup)
	enableStream := channelMgr != nil && channelMgr.IsStreamingChannel(msg.Channel, isGroup)

	// Group chats allow concurrent runs (multiple users can chat simultaneously).
	maxConcurrent := 1
	if peerKind == string(sessions.PeerGroup) {
		maxConcurrent = 3
	}

	runID := fmt.Sprintf("inbound-%s-%s-%s", msg.Channel, msg.ChatID, uuid.NewString()[:8])

	// Build outbound metadata for reply-to + thread routing BEFORE RegisterRun
	// so block.reply handler can use it for routing intermediate messages.
	outMeta := make(map[string]string)
	if isGroup {
		if mid := msg.Metadata["message_id"]; mid != "" {
			outMeta["reply_to_message_id"] = mid
		}
	}
	for _, k := range []string{"message_thread_id", "local_key", "placeholder_key", "group_id"} {
		if v := msg.Metadata[k]; v != "" {
			outMeta[k] = v
		}
	}

	// Register run with channel manager for streaming/reaction event forwarding.
	// Use localKey (composite key with topic suffix) so streaming/reaction events
	// route to the correct per-topic state in the channel.
	messageID := msg.Metadata["message_id"]
	chatIDForRun := msg.ChatID
	if lk := msg.Metadata["local_key"]; lk != "" {
		chatIDForRun = lk
	}
	blockReply := channelMgr != nil && channelMgr.ResolveBlockReply(msg.Channel, cfg.Gateway.BlockReply)
	toolStatus := cfg.Gateway.ToolStatus == nil || *cfg.Gateway.ToolStatus // default true
	if channelMgr != nil {
		channelMgr.RegisterRun(runID, msg.Channel, chatIDForRun, messageID, outMeta, enableStream, blockReply, toolStatus)
	}

	// Group-aware system prompt: help the LLM adapt tone and behavior for group chats.
	var extraPrompt string
	if peerKind == string(sessions.PeerGroup) {
		extraPrompt = "You are in a GROUP chat (multiple participants), not a private 1-on-1 DM.\n" +
			"- Messages may include a [Chat messages since your last reply] section with recent group history. Each history line shows \"sender [time]: message\".\n" +
			"- The current message includes a [From: sender_name] tag identifying who @mentioned you.\n" +
			"- Keep responses concise and focused; long replies are disruptive in groups.\n" +
			"- Address the group naturally. If the history shows a multi-person conversation, consider the full context before answering."
	}

	// Append per-topic system prompt (from group/topic config hierarchy).
	if tsp := msg.Metadata["topic_system_prompt"]; tsp != "" {
		if extraPrompt != "" {
			extraPrompt += "\n\n"
		}
		extraPrompt += tsp
	}

	// Per-topic skill filter override (from group/topic config hierarchy).
	var skillFilter []string
	if ts := msg.Metadata["topic_skills"]; ts != "" {
		skillFilter = strings.Split(ts, ",")
	}

	// Delegation announces carry media as ForwardMedia (not deleted, forwarded to output).
	// User-uploaded media goes in Media (loaded as images for LLM, then deleted).
	var reqMedia, fwdMedia []bus.MediaFile
	if msg.Metadata["delegation_id"] != "" || msg.Metadata["subagent_id"] != "" {
		fwdMedia = msg.Media
	} else {
		reqMedia = msg.Media
	}

	// Intent classify fast-path: when agent is busy on DM, classify user intent
	// to detect status queries, cancel requests, or steer/new_task for mid-run injection.
	// Only for DM (maxConcurrent=1) where messages queue behind the active run.
	if maxConcurrent == 1 && agents.IsSessionBusy(sessionKey) {
		if loop, ok := agentLoop.(*agent.Loop); ok && loop.Provider() != nil {
			locale := msg.Metadata["locale"]
			if locale == "" {
				locale = "en"
			}
			intent := agent.ClassifyIntent(ctx, loop.Provider(), loop.Model(), msg.Content)
			switch intent {
			case agent.IntentStatusQuery:
				status := agents.GetActivity(sessionKey)
				reply := agent.FormatStatusReply(status, locale)
				msgBus.PublishOutbound(bus.OutboundMessage{
					Channel:  msg.Channel,
					ChatID:   msg.ChatID,
					Content:  reply,
					Metadata: outMeta,
				})
				return
			case agent.IntentCancel:
				aborted := agents.AbortRunsForSession(sessionKey)
				if len(aborted) > 0 {
					slog.Info("inbound: cancelled runs via intent classify",
						"session", sessionKey, "aborted", aborted)
					msgBus.PublishOutbound(bus.OutboundMessage{
						Channel:  msg.Channel,
						ChatID:   msg.ChatID,
						Content:  i18n.T(locale, i18n.MsgCancelledReply),
						Metadata: outMeta,
					})
				}
				return
			case agent.IntentSteer, agent.IntentNewTask:
				// Mid-run injection: inject into the running loop instead of queueing.
				injected := agents.InjectMessage(sessionKey, agent.InjectedMessage{
					Content: msg.Content,
					UserID:  userID,
				})
				if injected {
					slog.Info("inbound: injected mid-run message",
						"intent", string(intent), "session", sessionKey)
					msgBus.PublishOutbound(bus.OutboundMessage{
						Channel:  msg.Channel,
						ChatID:   msg.ChatID,
						Content:  i18n.T(locale, i18n.MsgInjectedAck),
						Metadata: outMeta,
					})
					return
				}
				// Fallback: injection failed (channel full) → fall through to scheduler queue
				slog.Info("inbound: injection failed, queueing as normal",
					"intent", string(intent), "session", sessionKey)
			}
		}
	}

	// Inject post-turn dispatch tracker so team task creates are deferred.
	ptd := tools.NewPendingTeamDispatch()
	schedCtx := tools.WithPendingTeamDispatch(ctx, ptd)

	// Schedule through main lane (per-session concurrency controlled by maxConcurrent)
	outCh := sched.ScheduleWithOpts(schedCtx, "main", agent.RunRequest{
		SessionKey:        sessionKey,
		Message:           msg.Content,
		Media:             reqMedia,
		ForwardMedia:      fwdMedia,
		Channel:           msg.Channel,
		ChannelType:       resolveChannelType(channelMgr, msg.Channel),
		ChatID:            msg.ChatID,
		PeerKind:          peerKind,
		LocalKey:          msg.Metadata["local_key"],
		UserID:            userID,
		SenderID:          msg.SenderID,
		RunID:             runID,
		Stream:            enableStream,
		HistoryLimit:      msg.HistoryLimit,
		ToolAllow:         msg.ToolAllow,
		ExtraSystemPrompt: extraPrompt,
		SkillFilter:       skillFilter,
	}, scheduler.ScheduleOpts{
		MaxConcurrent: maxConcurrent,
	})

	// Handle result asynchronously to not block the flush callback.
	go func(agentKey, channel, chatID, session, rID string, meta map[string]string, blockReplyEnabled bool, ptd *tools.PendingTeamDispatch) {
		outcome := <-outCh

		// Release team create lock — tasks already visible in DB, other goroutines can list.
		ptd.ReleaseTeamLock()

		// Post-turn: dispatch pending team tasks created during this turn.
		if postTurn != nil {
			for teamID, taskIDs := range ptd.Drain() {
				if err := postTurn.ProcessPendingTasks(ctx, teamID, taskIDs); err != nil {
					slog.Warn("post_turn: failed", "team_id", teamID, "error", err)
				}
			}
		}

		// Clean up run tracking (in case HandleAgentEvent didn't fire for terminal events)
		if channelMgr != nil {
			channelMgr.UnregisterRun(rID)
		}

		if outcome.Err != nil {
			// Don't send error for cancelled runs (/stop command) —
			// publish empty outbound to clean up thinking/typing indicators.
			if errors.Is(outcome.Err, context.Canceled) {
				slog.Info("inbound: run cancelled", "channel", channel, "session", session)
				msgBus.PublishOutbound(bus.OutboundMessage{
					Channel:  channel,
					ChatID:   chatID,
					Content:  "",
					Metadata: meta,
				})
				return
			}
			slog.Error("inbound: agent run failed", "error", outcome.Err, "channel", channel)
			msgBus.PublishOutbound(bus.OutboundMessage{
				Channel:  channel,
				ChatID:   chatID,
				Content:  formatAgentError(outcome.Err),
				Metadata: meta,
			})
			return
		}

		// Suppress empty/NO_REPLY responses (matching TS normalize-reply.ts).
		// Still publish an empty outbound so channels can clean up placeholder/thinking indicators.
		if outcome.Result.Content == "" || agent.IsSilentReply(outcome.Result.Content) {
			slog.Info("inbound: suppressed silent/empty reply",
				"channel", channel,
				"chat_id", chatID,
				"session", session,
			)
			msgBus.PublishOutbound(bus.OutboundMessage{
				Channel:  channel,
				ChatID:   chatID,
				Content:  "",
				Metadata: meta,
			})
			return
		}

		// Dedup: if block replies were delivered and the final content matches the last
		// block reply, suppress the final message to avoid duplicate delivery.
		// Only applies when blockReply is enabled (otherwise nothing was delivered).
		if blockReplyEnabled && outcome.Result.BlockReplies > 0 && outcome.Result.Content == outcome.Result.LastBlockReply && len(outcome.Result.Media) == 0 {
			slog.Debug("inbound: dedup final message (matches last block reply)",
				"channel", channel, "run_id", rID)
			msgBus.PublishOutbound(bus.OutboundMessage{
				Channel:  channel,
				ChatID:   chatID,
				Content:  "",
				Metadata: meta,
			})
			return
		}

		// Publish response back to the channel
		outMsg := bus.OutboundMessage{
			Channel:  channel,
			ChatID:   chatID,
			Content:  outcome.Result.Content,
			Metadata: meta,
		}

		appendMediaToOutbound(&outMsg, outcome.Result.Media)

		msgBus.PublishOutbound(outMsg)

		// Auto-set followup when lead agent replies on a real channel with in_progress tasks.
		if teamStore != nil && channel != tools.ChannelSystem && channel != tools.ChannelTeammate && channel != tools.ChannelDashboard {
			go autoSetFollowup(ctx, teamStore, agentStore, agentKey, channel, chatID, outcome.Result.Content)
		}
	}(agentID, msg.Channel, msg.ChatID, sessionKey, runID, outMeta, blockReply, ptd)
}
