package discord

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/bwmarrin/discordgo"
	"github.com/gorilla/websocket"

	"github.com/sipeed/picoclaw/pkg/audio"
	"github.com/sipeed/picoclaw/pkg/audio/tts"
	"github.com/sipeed/picoclaw/pkg/bus"
	"github.com/sipeed/picoclaw/pkg/channels"
	"github.com/sipeed/picoclaw/pkg/config"
	"github.com/sipeed/picoclaw/pkg/identity"
	"github.com/sipeed/picoclaw/pkg/logger"
	"github.com/sipeed/picoclaw/pkg/media"
	"github.com/sipeed/picoclaw/pkg/utils"
)

const (
	sendTimeout = 10 * time.Second
)

var (
	// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
	channelRefRe = regexp.MustCompile(`<#(\d+)>`)
	msgLinkRe    = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
)

type DiscordChannel struct {
	*channels.BaseChannel
	bc         *config.Channel
	session    *discordgo.Session
	config     *config.DiscordSettings
	ctx        context.Context
	cancel     context.CancelFunc
	typingMu   sync.Mutex
	typingStop map[string]chan struct{} // chatID → stop signal
	progress   *channels.ToolFeedbackAnimator
	botUserID  string // stored for mention checking
	bus        *bus.MessageBus
	tts        tts.TTSProvider
	playTTSFn  func(context.Context, *discordgo.VoiceConnection, string, uint64)
	ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool)
	voiceMu    sync.RWMutex
	voiceSSRC  map[string]map[uint32]string // guildID -> ssrc -> userID

	// TTS interruption: cancel active playback when user speaks
	ttsMu     sync.Mutex
	cancelTTS context.CancelFunc
	ttsPlayID uint64
}

func NewDiscordChannel(
	bc *config.Channel,
	cfg *config.DiscordSettings,
	bus *bus.MessageBus,
) (*DiscordChannel, error) {
	discordgo.Logger = logger.NewLogger("discord").
		WithLevels(map[int]logger.LogLevel{
			discordgo.LogError:         logger.ERROR,
			discordgo.LogWarning:       logger.WARN,
			discordgo.LogInformational: logger.INFO,
			discordgo.LogDebug:         logger.DEBUG,
		}).Log

	session, err := discordgo.New("Bot " + cfg.Token.String())
	if err != nil {
		return nil, fmt.Errorf("failed to create discord session: %w", err)
	}

	if err := applyDiscordProxy(session, cfg.Proxy); err != nil {
		return nil, err
	}
	base := channels.NewBaseChannel("discord", cfg, bus, bc.AllowFrom,
		channels.WithMaxMessageLength(2000),
		channels.WithGroupTrigger(bc.GroupTrigger),
		channels.WithReasoningChannelID(bc.ReasoningChannelID),
	)

	ch := &DiscordChannel{
		BaseChannel: base,
		bc:          bc,
		session:     session,
		config:      cfg,
		ctx:         context.Background(),
		typingStop:  make(map[string]chan struct{}),
		bus:         bus,
		voiceSSRC:   make(map[string]map[uint32]string),
	}
	ch.playTTSFn = ch.playTTS
	ch.ttsVoiceFn = ch.voiceConnectionForTTS
	ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
	return ch, nil
}

func (c *DiscordChannel) Start(ctx context.Context) error {
	logger.InfoC("discord", "Starting Discord bot")

	c.ctx, c.cancel = context.WithCancel(ctx)

	// Get bot user ID before opening session to avoid race condition
	botUser, err := c.session.User("@me")
	if err != nil {
		return fmt.Errorf("failed to get bot user: %w", err)
	}
	c.botUserID = botUser.ID

	c.session.AddHandler(c.handleMessage)

	go c.listenVoiceControl(c.ctx)

	if err := c.session.Open(); err != nil {
		return fmt.Errorf("failed to open discord session: %w", err)
	}

	c.SetRunning(true)

	logger.InfoCF("discord", "Discord bot connected", map[string]any{
		"username": botUser.Username,
		"user_id":  botUser.ID,
	})

	return nil
}

func (c *DiscordChannel) Stop(ctx context.Context) error {
	logger.InfoC("discord", "Stopping Discord bot")
	c.SetRunning(false)

	// Stop all typing goroutines before closing session
	c.typingMu.Lock()
	for chatID, stop := range c.typingStop {
		close(stop)
		delete(c.typingStop, chatID)
	}
	c.typingMu.Unlock()

	// Cancel our context so typing goroutines using c.ctx.Done() exit
	if c.cancel != nil {
		c.cancel()
	}
	if c.progress != nil {
		c.progress.StopAll()
	}

	if err := c.session.Close(); err != nil {
		return fmt.Errorf("failed to close discord session: %w", err)
	}

	return nil
}

func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
	if !c.IsRunning() {
		return nil, channels.ErrNotRunning
	}

	channelID := msg.ChatID
	if channelID == "" {
		return nil, fmt.Errorf("channel ID is empty")
	}

	if len([]rune(msg.Content)) == 0 {
		return nil, nil
	}

	isToolFeedback := outboundMessageIsToolFeedback(msg)
	if isToolFeedback {
		if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled {
			if err != nil {
				return nil, err
			}
			return []string{msgID}, nil
		}
	}
	trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID)
	c.maybeStartTTS(channelID, msg.Content, isToolFeedback)
	if !isToolFeedback {
		if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled {
			return msgIDs, nil
		}
	}

	content := msg.Content
	if isToolFeedback {
		content = channels.InitialAnimatedToolFeedbackContent(msg.Content)
	}
	msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID)
	if err != nil {
		return nil, err
	}
	if isToolFeedback {
		c.RecordToolFeedbackMessage(channelID, msgID, msg.Content)
	} else if hasTrackedMsg {
		c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID)
	}
	return []string{msgID}, nil
}

func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) {
	if c.tts == nil || isToolFeedback {
		return
	}

	voiceFn := c.ttsVoiceFn
	if voiceFn == nil {
		voiceFn = c.voiceConnectionForTTS
	}
	vc, ok := voiceFn(channelID)
	if !ok || vc == nil {
		return
	}

	// Cancel any previous TTS playback.
	c.ttsMu.Lock()
	if c.cancelTTS != nil {
		c.cancelTTS()
	}
	ttsCtx, ttsCancel := context.WithCancel(c.ctx)
	c.ttsPlayID++
	playID := c.ttsPlayID
	c.cancelTTS = ttsCancel
	playFn := c.playTTSFn
	c.ttsMu.Unlock()

	if playFn == nil {
		playFn = c.playTTS
	}
	go playFn(ttsCtx, vc, content, playID)
}

func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) {
	if c.session == nil || c.session.State == nil {
		return nil, false
	}

	ch, err := c.session.State.Channel(channelID)
	if err != nil || ch == nil || ch.GuildID == "" {
		return nil, false
	}

	vc, ok := c.session.VoiceConnections[ch.GuildID]
	if !ok || vc == nil {
		return nil, false
	}
	return vc, true
}

// SendMedia implements the channels.MediaSender interface.
func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
	if !c.IsRunning() {
		return nil, channels.ErrNotRunning
	}

	channelID := msg.ChatID
	if channelID == "" {
		return nil, fmt.Errorf("channel ID is empty")
	}
	trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID)

	store := c.GetMediaStore()
	if store == nil {
		return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
	}

	// Collect all files into a single ChannelMessageSendComplex call
	files := make([]*discordgo.File, 0, len(msg.Parts))
	var caption string

	for _, part := range msg.Parts {
		localPath, err := store.Resolve(part.Ref)
		if err != nil {
			logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
				"ref":   part.Ref,
				"error": err.Error(),
			})
			continue
		}

		file, err := os.Open(localPath)
		if err != nil {
			logger.ErrorCF("discord", "Failed to open media file", map[string]any{
				"path":  localPath,
				"error": err.Error(),
			})
			continue
		}
		// Note: discordgo reads from the Reader and we can't close it before send

		filename := part.Filename
		if filename == "" {
			filename = "file"
		}

		files = append(files, &discordgo.File{
			Name:        filename,
			ContentType: part.ContentType,
			Reader:      file,
		})

		if part.Caption != "" && caption == "" {
			caption = part.Caption
		}
	}

	if len(files) == 0 {
		return nil, nil
	}

	sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
	defer cancel()

	type mediaResult struct {
		id  string
		err error
	}
	done := make(chan mediaResult, 1)
	go func() {
		sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
			Content: caption,
			Files:   files,
		})
		if err != nil {
			done <- mediaResult{err: err}
			return
		}
		done <- mediaResult{id: sentMsg.ID}
	}()

	select {
	case r := <-done:
		// Close all file readers
		for _, f := range files {
			if closer, ok := f.Reader.(*os.File); ok {
				closer.Close()
			}
		}
		if r.err != nil {
			return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary)
		}
		if hasTrackedMsg {
			c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID)
		}
		return []string{r.id}, nil
	case <-sendCtx.Done():
		// Close all file readers
		for _, f := range files {
			if closer, ok := f.Reader.(*os.File); ok {
				closer.Close()
			}
		}
		return nil, sendCtx.Err()
	}
}

// EditMessage implements channels.MessageEditor.
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
	_, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx))
	return err
}

// DeleteMessage implements channels.MessageDeleter.
func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
	return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx))
}

// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message that will later be edited to the actual
// response via EditMessage (channels.MessageEditor).
func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
	if !c.bc.Placeholder.Enabled {
		return "", nil
	}

	text := c.bc.Placeholder.GetRandomText()

	msg, err := c.session.ChannelMessageSend(chatID, text)
	if err != nil {
		return "", err
	}

	return msg.ID, nil
}

func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
	if len(msg.Context.Raw) == 0 {
		return false
	}
	return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
}

func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
	if c.progress == nil {
		return "", false
	}
	return c.progress.Current(chatID)
}

func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) {
	if c.progress == nil {
		return "", "", false
	}
	return c.progress.Take(chatID)
}

func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
	if c.progress == nil {
		return
	}
	c.progress.Record(chatID, messageID, content)
}

func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) {
	if c.progress == nil {
		return
	}
	c.progress.Clear(chatID)
}

func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
	msgID, ok := c.currentToolFeedbackMessage(chatID)
	if !ok {
		return
	}
	c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
}

func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) {
	if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" {
		return
	}
	c.ClearToolFeedbackMessage(chatID)
	_ = c.DeleteMessage(ctx, chatID, messageID)
}

func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage(
	ctx context.Context,
	chatID string,
	content string,
	editFn func(context.Context, string, string, string) error,
) ([]string, bool) {
	msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
	if !ok || editFn == nil {
		return nil, false
	}
	if err := editFn(ctx, chatID, msgID, content); err != nil {
		c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
		return nil, false
	}
	return []string{msgID}, true
}

func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
	if outboundMessageIsToolFeedback(msg) {
		return nil, false
	}
	return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage)
}

func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) {
	// Use the passed ctx for timeout control
	sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
	defer cancel()

	type result struct {
		id  string
		err error
	}
	done := make(chan result, 1)
	go func() {
		var (
			msg *discordgo.Message
			err error
		)

		// If we have an ID, we send the message as "Reply"
		if replyToID != "" {
			msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
				Content: content,
				Reference: &discordgo.MessageReference{
					MessageID: replyToID,
					ChannelID: channelID,
				},
			})
		} else {
			// Otherwise, we send a normal message
			msg, err = c.session.ChannelMessageSend(channelID, content)
		}

		if err != nil {
			done <- result{err: fmt.Errorf("discord send: %w", channels.ErrTemporary)}
			return
		}
		done <- result{id: msg.ID}
	}()

	select {
	case r := <-done:
		return r.id, r.err
	case <-sendCtx.Done():
		return "", sendCtx.Err()
	}
}

// appendContent safely appends content to existing text
func appendContent(content, suffix string) string {
	if content == "" {
		return suffix
	}
	return content + "\n" + suffix
}

func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
	if m == nil || m.Author == nil {
		return
	}

	if m.Author.ID == s.State.User.ID {
		return
	}

	// Check allowlist first to avoid downloading attachments for rejected users
	sender := bus.SenderInfo{
		Platform:    "discord",
		PlatformID:  m.Author.ID,
		CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
		Username:    m.Author.Username,
	}
	// Build display name
	displayName := m.Author.Username
	if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
		displayName += "#" + m.Author.Discriminator
	}
	sender.DisplayName = displayName

	if !c.IsAllowedSender(sender) {
		logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
			"user_id": m.Author.ID,
		})
		return
	}

	if c.handleVoiceCommand(s, m) {
		return
	}

	content := m.Content

	// In guild (group) channels, apply unified group trigger filtering
	// DMs (GuildID is empty) always get a response
	isMentioned := false
	if m.GuildID != "" {
		for _, mention := range m.Mentions {
			if mention.ID == c.botUserID {
				isMentioned = true
				break
			}
		}
		content = c.stripBotMention(content)
		respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
		if !respond {
			logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
				"user_id": m.Author.ID,
			})
			return
		}
		content = cleaned
	} else {
		// DMs: just strip bot mention without filtering
		content = c.stripBotMention(content)
	}

	// Resolve Discord refs in main content before concatenation to avoid
	// double-expanding links that appear in the referenced message.
	content = c.resolveDiscordRefs(s, content, m.GuildID)

	// Prepend referenced (quoted) message content if this is a reply
	if m.MessageReference != nil && m.ReferencedMessage != nil {
		refContent := m.ReferencedMessage.Content
		if refContent != "" {
			refAuthor := "unknown"
			if m.ReferencedMessage.Author != nil {
				refAuthor = m.ReferencedMessage.Author.Username
			}
			refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
			content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
				refAuthor, refContent, content)
		}
	}

	senderID := m.Author.ID

	mediaPaths := make([]string, 0, len(m.Attachments))

	scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)

	// Helper to register a local file with the media store
	storeMedia := func(localPath, filename string) string {
		if store := c.GetMediaStore(); store != nil {
			ref, err := store.Store(localPath, media.MediaMeta{
				Filename:      filename,
				Source:        "discord",
				CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
			}, scope)
			if err == nil {
				return ref
			}
		}
		return localPath // fallback
	}

	for _, attachment := range m.Attachments {
		isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)

		if isAudio {
			localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
			if localPath != "" {
				mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
				content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
			} else {
				logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
					"url":      attachment.URL,
					"filename": attachment.Filename,
				})
				mediaPaths = append(mediaPaths, attachment.URL)
				content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
			}
		} else {
			mediaPaths = append(mediaPaths, attachment.URL)
			content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
		}
	}

	if content == "" && len(mediaPaths) == 0 {
		return
	}

	if content == "" {
		content = "[media only]"
	}

	logger.DebugCF("discord", "Received message", map[string]any{
		"sender_name": sender.DisplayName,
		"sender_id":   senderID,
		"preview":     utils.Truncate(content, 50),
	})

	peerKind := "channel"
	if m.GuildID == "" {
		peerKind = "direct"
	}

	metadata := map[string]string{
		"user_id":      senderID,
		"username":     m.Author.Username,
		"display_name": sender.DisplayName,
		"guild_id":     m.GuildID,
		"channel_id":   m.ChannelID,
		"is_dm":        fmt.Sprintf("%t", m.GuildID == ""),
	}
	inboundCtx := bus.InboundContext{
		Channel:   c.Name(),
		ChatID:    m.ChannelID,
		ChatType:  peerKind,
		SenderID:  senderID,
		MessageID: m.ID,
		Mentioned: isMentioned,
		Raw:       metadata,
	}
	if m.GuildID != "" {
		inboundCtx.SpaceID = m.GuildID
		inboundCtx.SpaceType = "guild"
	}
	if m.MessageReference != nil {
		inboundCtx.ReplyToMessageID = m.MessageReference.MessageID
	}

	c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender)
}

// startTyping starts a continuous typing indicator loop for the given chatID.
// It stops any existing typing loop for that chatID before starting a new one.
func (c *DiscordChannel) startTyping(chatID string) {
	c.typingMu.Lock()
	// Stop existing loop for this chatID if any
	if stop, ok := c.typingStop[chatID]; ok {
		close(stop)
	}
	stop := make(chan struct{})
	c.typingStop[chatID] = stop
	c.typingMu.Unlock()

	go func() {
		if err := c.session.ChannelTyping(chatID); err != nil {
			logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
		}
		ticker := time.NewTicker(8 * time.Second)
		defer ticker.Stop()
		timeout := time.After(5 * time.Minute)
		for {
			select {
			case <-stop:
				return
			case <-timeout:
				return
			case <-c.ctx.Done():
				return
			case <-ticker.C:
				if err := c.session.ChannelTyping(chatID); err != nil {
					logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
				}
			}
		}
	}()
}

// stopTyping stops the typing indicator loop for the given chatID.
func (c *DiscordChannel) stopTyping(chatID string) {
	c.typingMu.Lock()
	defer c.typingMu.Unlock()
	if stop, ok := c.typingStop[chatID]; ok {
		close(stop)
		delete(c.typingStop, chatID)
	}
}

// StartTyping implements channels.TypingCapable.
// It starts a continuous typing indicator and returns an idempotent stop function.
func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
	c.startTyping(chatID)
	return func() { c.stopTyping(chatID) }, nil
}

func (c *DiscordChannel) downloadAttachment(url, filename string) string {
	return utils.DownloadFile(url, filename, utils.DownloadOptions{
		LoggerPrefix: "discord",
		ProxyURL:     c.config.Proxy,
	})
}

func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
	var proxyFunc func(*http.Request) (*url.URL, error)
	if proxyAddr != "" {
		proxyURL, err := url.Parse(proxyAddr)
		if err != nil {
			return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err)
		}
		proxyFunc = http.ProxyURL(proxyURL)
	} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
		proxyFunc = http.ProxyFromEnvironment
	}

	if proxyFunc == nil {
		return nil
	}

	transport := &http.Transport{Proxy: proxyFunc}
	session.Client = &http.Client{
		Timeout:   sendTimeout,
		Transport: transport,
	}

	if session.Dialer != nil {
		dialerCopy := *session.Dialer
		dialerCopy.Proxy = proxyFunc
		session.Dialer = &dialerCopy
	} else {
		session.Dialer = &websocket.Dialer{Proxy: proxyFunc}
	}

	return nil
}

// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
// expands Discord message links to show the linked message content.
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
	// 1. Resolve channel references: <#id> → #channel-name
	text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
		parts := channelRefRe.FindStringSubmatch(match)
		if len(parts) < 2 {
			return match
		}
		// Prefer session state cache to avoid API calls
		if ch, err := s.State.Channel(parts[1]); err == nil {
			return "#" + ch.Name
		}
		if ch, err := s.Channel(parts[1]); err == nil {
			return "#" + ch.Name
		}
		return match
	})

	// 2. Expand Discord message links (max 3, same guild only)
	matches := msgLinkRe.FindAllStringSubmatch(text, 3)
	for _, m := range matches {
		if len(m) < 4 {
			continue
		}
		linkGuildID, channelID, messageID := m[1], m[2], m[3]
		// Security: only expand links from the same guild
		if linkGuildID != guildID {
			continue
		}
		msg, err := s.ChannelMessage(channelID, messageID)
		if err != nil || msg == nil || msg.Content == "" {
			continue
		}
		author := "unknown"
		if msg.Author != nil {
			author = msg.Author.Username
		}
		text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
	}

	return text
}

// stripBotMention removes the bot mention from the message content.
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
func (c *DiscordChannel) stripBotMention(text string) string {
	if c.botUserID == "" {
		return text
	}
	// Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
	text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
	text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
	return strings.TrimSpace(text)
}

func (c *DiscordChannel) listenVoiceControl(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			return
		case ctrl, ok := <-c.bus.VoiceControlsChan():
			if !ok {
				return
			}
			if ctrl.Type == "command" && ctrl.Action == "leave" {
				if strings.HasPrefix(ctrl.SessionID, "discord_vc_") {
					guildID := strings.TrimPrefix(ctrl.SessionID, "discord_vc_")
					vc, exists := c.session.VoiceConnections[guildID]
					if exists && vc != nil {
						vc.Disconnect(ctx)
					}
				}
			}
		}
	}
}

func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string, playID uint64) {
	// Capture the cancel func associated with this playback (if any).
	// Clear cancelTTS when playback finishes (normal or interrupted),
	// but only if it still refers to this playback's cancel func.
	defer func() {
		c.ttsMu.Lock()
		if c.ttsPlayID == playID {
			c.cancelTTS = nil
		}
		c.ttsMu.Unlock()
	}()

	sentences := audio.SplitSentences(text)
	if len(sentences) == 0 {
		return
	}

	logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)})

	// Pipeline: prefetch next sentence's audio while playing current
	type ttResult struct {
		stream io.ReadCloser
		err    error
	}

	var prefetch chan ttResult

	// Ensure any in-flight prefetch is drained on exit to prevent stream leaks,
	// but avoid blocking indefinitely if the prefetch goroutine is stuck or never sends.
	defer func() {
		if prefetch != nil {
			select {
			case result := <-prefetch:
				if result.stream != nil {
					result.stream.Close()
				}
			case <-time.After(100 * time.Millisecond):
				// Timed out waiting for a prefetched result; avoid blocking on exit.
			}
		}
	}()

	for i, sentence := range sentences {
		// Check for cancellation (interruption)
		select {
		case <-ctx.Done():
			logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i})
			return
		default:
		}

		// Start prefetching the NEXT sentence while we process the current one
		var nextPrefetch chan ttResult
		if i+1 < len(sentences) {
			nextPrefetch = make(chan ttResult, 1)
			nextSentence := sentences[i+1]
			go func() {
				s, e := c.tts.Synthesize(ctx, nextSentence)
				nextPrefetch <- ttResult{s, e}
			}()
		}

		// Get the current sentence's audio
		var stream io.ReadCloser
		var err error

		if prefetch != nil {
			// Use prefetched result from previous iteration, but be responsive to cancellation.
			var result ttResult
			select {
			case result = <-prefetch:
				stream, err = result.stream, result.err
			case <-ctx.Done():
				// Context canceled while waiting for prefetched audio; abort playback.
				logger.InfoCF(
					"discord",
					"TTS interrupted while waiting for prefetched audio",
					map[string]any{"at_sentence": i},
				)
				return
			}
		} else {
			// First sentence: synthesize directly
			stream, err = c.tts.Synthesize(ctx, sentence)
		}

		if err != nil {
			if stream != nil {
				stream.Close()
			}
			logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i})
			prefetch = nextPrefetch
			continue
		}

		if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil {
			logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i})
		}
		stream.Close()

		prefetch = nextPrefetch
	}
}

// VoiceCapabilities returns the voice capabilities of the channel.
func (c *DiscordChannel) VoiceCapabilities() channels.VoiceCapabilities {
	return channels.VoiceCapabilities{ASR: true, TTS: true}
}
