package qq

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/tencent-connect/botgo"
	"github.com/tencent-connect/botgo/constant"
	"github.com/tencent-connect/botgo/dto"
	"github.com/tencent-connect/botgo/event"
	"github.com/tencent-connect/botgo/openapi/options"
	"github.com/tencent-connect/botgo/token"
	"golang.org/x/oauth2"

	"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 (
	dedupTTL      = 5 * time.Minute
	dedupInterval = 60 * time.Second
	dedupMaxSize  = 10000 // hard cap on dedup map entries
	typingResend  = 8 * time.Second
	typingSeconds = 10
	bytesPerMiB   = 1024 * 1024
)

type qqAPI interface {
	WS(ctx context.Context, params map[string]string, body string) (*dto.WebsocketAP, error)
	PostGroupMessage(
		ctx context.Context, groupID string, msg dto.APIMessage, opt ...options.Option,
	) (*dto.Message, error)
	PostC2CMessage(
		ctx context.Context, userID string, msg dto.APIMessage, opt ...options.Option,
	) (*dto.Message, error)
	Transport(ctx context.Context, method, url string, body any) ([]byte, error)
}

type QQChannel struct {
	*channels.BaseChannel
	bc             *config.Channel
	config         *config.QQSettings
	api            qqAPI
	tokenSource    oauth2.TokenSource
	ctx            context.Context
	cancel         context.CancelFunc
	sessionManager botgo.SessionManager
	downloadFn     func(urlStr, filename string) string

	// Chat routing: track whether a chatID is group or direct.
	chatType sync.Map // chatID → "group" | "direct"

	// Passive reply: store last inbound message ID per chat.
	lastMsgID sync.Map // chatID → string

	// msg_seq: per-chat atomic counter for multi-part replies.
	msgSeqCounters sync.Map // chatID → *atomic.Uint64

	// Time-based dedup replacing the unbounded map.
	dedup   map[string]time.Time
	muDedup sync.Mutex

	// done is closed on Stop to shut down the dedup janitor.
	done     chan struct{}
	stopOnce sync.Once
}

func NewQQChannel(bc *config.Channel, cfg *config.QQSettings, messageBus *bus.MessageBus) (*QQChannel, error) {
	base := channels.NewBaseChannel("qq", cfg, messageBus, bc.AllowFrom,
		channels.WithMaxMessageLength(cfg.MaxMessageLength),
		channels.WithGroupTrigger(bc.GroupTrigger),
		channels.WithReasoningChannelID(bc.ReasoningChannelID),
	)

	return &QQChannel{
		BaseChannel: base,
		bc:          bc,
		config:      cfg,
		dedup:       make(map[string]time.Time),
		done:        make(chan struct{}),
	}, nil
}

func (c *QQChannel) Start(ctx context.Context) error {
	if c.config.AppID == "" || c.config.AppSecret.String() == "" {
		return fmt.Errorf("QQ app_id and app_secret not configured")
	}

	botgo.SetLogger(newBotGoLogger("botgo"))
	logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")

	// Reinitialize shutdown signal for clean restart.
	c.done = make(chan struct{})
	c.stopOnce = sync.Once{}

	// create token source
	credentials := &token.QQBotCredentials{
		AppID:     c.config.AppID,
		AppSecret: c.config.AppSecret.String(),
	}
	c.tokenSource = token.NewQQBotTokenSource(credentials)

	// create child context
	c.ctx, c.cancel = context.WithCancel(ctx)

	// start auto-refresh token goroutine
	if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
		return fmt.Errorf("failed to start token refresh: %w", err)
	}

	// initialize OpenAPI client
	c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)

	// register event handlers
	intent := event.RegisterHandlers(
		c.handleC2CMessage(),
		c.handleGroupATMessage(),
	)

	// get WebSocket endpoint
	wsInfo, err := c.api.WS(c.ctx, nil, "")
	if err != nil {
		return fmt.Errorf("failed to get websocket info: %w", err)
	}

	logger.InfoCF("qq", "Got WebSocket info", map[string]any{
		"shards": wsInfo.Shards,
	})

	// create and save sessionManager
	c.sessionManager = botgo.NewSessionManager()

	// start WebSocket connection in goroutine to avoid blocking
	go func() {
		if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
			logger.ErrorCF("qq", "WebSocket session error", map[string]any{
				"error": err.Error(),
			})
			c.SetRunning(false)
		}
	}()

	// start dedup janitor goroutine
	go c.dedupJanitor()

	// Pre-register reasoning_channel_id as group chat if configured,
	// so outbound-only destinations are routed correctly.
	if c.bc.ReasoningChannelID != "" {
		c.chatType.Store(c.bc.ReasoningChannelID, "group")
	}

	c.SetRunning(true)
	logger.InfoC("qq", "QQ bot started successfully")

	return nil
}

func (c *QQChannel) Stop(ctx context.Context) error {
	logger.InfoC("qq", "Stopping QQ bot")
	c.SetRunning(false)

	// Signal the dedup janitor to stop (idempotent).
	c.stopOnce.Do(func() { close(c.done) })

	if c.cancel != nil {
		c.cancel()
	}

	return nil
}

// getChatKind returns the chat type for a given chatID ("group" or "direct").
// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are
// more common as outbound-only destinations (e.g. reasoning_channel_id).
func (c *QQChannel) getChatKind(chatID string) string {
	if v, ok := c.chatType.Load(chatID); ok {
		if k, ok := v.(string); ok {
			return k
		}
	}
	logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{
		"chat_id": chatID,
	})
	return "group"
}

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

	chatKind := c.getChatKind(msg.ChatID)

	// Build message with content.
	msgToCreate := &dto.MessageToCreate{
		Content: msg.Content,
		MsgType: dto.TextMsg,
	}

	// Use Markdown message type if enabled in config.
	if c.config.SendMarkdown {
		msgToCreate.MsgType = dto.MarkdownMsg
		msgToCreate.Markdown = &dto.Markdown{
			Content: msg.Content,
		}
		// Clear plain content to avoid sending duplicate text.
		msgToCreate.Content = ""
	}

	c.applyPassiveReplyMetadata(msg.ChatID, msgToCreate)

	// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
	if chatKind == "group" {
		if msgToCreate.Content != "" {
			msgToCreate.Content = sanitizeURLs(msgToCreate.Content)
		}
		if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" {
			msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)
		}
	}

	// Route to group or C2C.
	var (
		sentMsg *dto.Message
		err     error
	)
	if chatKind == "group" {
		sentMsg, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
	} else {
		sentMsg, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
	}

	if err != nil {
		logger.ErrorCF("qq", "Failed to send message", map[string]any{
			"chat_id":   msg.ChatID,
			"chat_kind": chatKind,
			"error":     err.Error(),
		})
		return nil, fmt.Errorf("qq send: %w", channels.ErrTemporary)
	}

	if sentMsg == nil {
		return nil, nil
	}
	return []string{sentMsg.ID}, nil
}

// StartTyping implements channels.TypingCapable.
// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.
// The returned stop function is idempotent and cancels the goroutine.
func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
	// We need a stored msg_id for passive InputNotify; skip if none available.
	v, ok := c.lastMsgID.Load(chatID)
	if !ok {
		return func() {}, nil
	}
	msgID, ok := v.(string)
	if !ok || msgID == "" {
		return func() {}, nil
	}

	chatKind := c.getChatKind(chatID)

	sendTyping := func(sendCtx context.Context) {
		typingMsg := &dto.MessageToCreate{
			MsgType: dto.InputNotifyMsg,
			MsgID:   msgID,
			InputNotify: &dto.InputNotify{
				InputType:   1,
				InputSecond: typingSeconds,
			},
		}

		var err error
		if chatKind == "group" {
			_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)
		} else {
			_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)
		}
		if err != nil {
			logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{
				"chat_id": chatID,
				"error":   err.Error(),
			})
		}
	}

	// Send immediately.
	sendTyping(c.ctx)

	typingCtx, cancel := context.WithCancel(c.ctx)
	go func() {
		ticker := time.NewTicker(typingResend)
		defer ticker.Stop()
		for {
			select {
			case <-typingCtx.Done():
				return
			case <-ticker.C:
				sendTyping(typingCtx)
			}
		}
	}()

	return cancel, nil
}

// SendMedia implements the channels.MediaSender interface.
// QQ group/C2C media sending is a two-step flow:
// 1. Upload media to /files using a remote URL or base64-encoded local bytes.
// 2. Send a msg_type=7 message using the returned file_info.
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
	if !c.IsRunning() {
		return nil, channels.ErrNotRunning
	}

	chatKind := c.getChatKind(msg.ChatID)

	var messageIDs []string
	for _, part := range msg.Parts {
		fileInfo, err := c.uploadMedia(ctx, chatKind, msg.ChatID, part)
		if err != nil {
			logger.ErrorCF("qq", "Failed to upload media", map[string]any{
				"type":    part.Type,
				"chat_id": msg.ChatID,
				"error":   err.Error(),
			})
			if errors.Is(err, channels.ErrSendFailed) {
				return nil, err
			}
			return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary)
		}

		sentMsg, err := c.sendUploadedMedia(ctx, chatKind, msg.ChatID, part, fileInfo)
		if err != nil {
			logger.ErrorCF("qq", "Failed to send media", map[string]any{
				"type":    part.Type,
				"chat_id": msg.ChatID,
				"error":   err.Error(),
			})
			return nil, fmt.Errorf("qq send media: %w", channels.ErrTemporary)
		}
		if sentMsg != nil && sentMsg.ID != "" {
			messageIDs = append(messageIDs, sentMsg.ID)
		}
	}

	return messageIDs, nil
}

type qqMediaUpload struct {
	FileType   uint64 `json:"file_type"`
	URL        string `json:"url,omitempty"`
	FileData   string `json:"file_data,omitempty"`
	FileName   string `json:"file_name,omitempty"`
	SrvSendMsg bool   `json:"srv_send_msg,omitempty"`
}

func (c *QQChannel) uploadMedia(
	ctx context.Context,
	chatKind, chatID string,
	part bus.MediaPart,
) ([]byte, error) {
	payload, err := c.buildMediaUpload(part)
	if err != nil {
		return nil, err
	}

	body, err := c.api.Transport(ctx, http.MethodPost, c.mediaUploadURL(chatKind, chatID), payload)
	if err != nil {
		return nil, err
	}

	var uploaded dto.Message
	if err := json.Unmarshal(body, &uploaded); err != nil {
		return nil, fmt.Errorf("qq decode media upload response: %w", err)
	}
	if len(uploaded.FileInfo) == 0 {
		return nil, fmt.Errorf("qq upload media: missing file_info")
	}

	return uploaded.FileInfo, nil
}

func (c *QQChannel) buildMediaUpload(part bus.MediaPart) (*qqMediaUpload, error) {
	payload := &qqMediaUpload{}

	mediaRef := part.Ref
	if isHTTPURL(mediaRef) {
		payload.FileType = qqFileType(c.outboundMediaType(part, ""))
		payload.URL = mediaRef
		payload.FileName = qqUploadFilename(part, mediaRef, payload.FileType)
		return payload, nil
	}

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

	resolved, meta, err := store.ResolveWithMeta(part.Ref)
	if err != nil {
		return nil, fmt.Errorf("qq resolve media ref %q: %v: %w", part.Ref, err, channels.ErrSendFailed)
	}
	if part.Filename == "" {
		part.Filename = meta.Filename
	}
	if part.ContentType == "" {
		part.ContentType = meta.ContentType
	}

	if isHTTPURL(resolved) {
		payload.FileType = qqFileType(c.outboundMediaType(part, ""))
		payload.URL = resolved
		payload.FileName = qqUploadFilename(part, resolved, payload.FileType)
		return payload, nil
	}
	payload.FileType = qqFileType(c.outboundMediaType(part, resolved))
	payload.FileName = qqUploadFilename(part, resolved, payload.FileType)

	if limitBytes := c.maxBase64FileSizeBytes(); limitBytes > 0 {
		info, statErr := os.Stat(resolved)
		if statErr != nil {
			return nil, fmt.Errorf("qq stat local media %q: %v: %w", resolved, statErr, channels.ErrSendFailed)
		}
		if info.Size() > limitBytes {
			return nil, fmt.Errorf(
				"qq local media %q exceeds max_base64_file_size_mib (%d > %d bytes): %w",
				resolved,
				info.Size(),
				limitBytes,
				channels.ErrSendFailed,
			)
		}
	}

	data, err := os.ReadFile(resolved)
	if err != nil {
		return nil, fmt.Errorf("qq read local media %q: %v: %w", resolved, err, channels.ErrSendFailed)
	}

	payload.FileData = base64.StdEncoding.EncodeToString(data)
	return payload, nil
}

func qqUploadFilename(part bus.MediaPart, resolved string, fileType uint64) string {
	if fileType != qqFileType("file") {
		return ""
	}
	if part.Filename != "" {
		return part.Filename
	}
	if isHTTPURL(resolved) {
		if parsed, err := url.Parse(resolved); err == nil {
			if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
				return base
			}
		}
		return ""
	}

	if base := filepath.Base(resolved); base != "" && base != "." {
		return base
	}
	return ""
}

func (c *QQChannel) outboundMediaType(part bus.MediaPart, localPath string) string {
	if part.Type != "audio" {
		return part.Type
	}

	if localPath == "" {
		logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
			"ref":      part.Ref,
			"filename": part.Filename,
		})
		return "file"
	}

	duration, ok, err := qqAudioDuration(localPath, part.Filename, part.ContentType)
	if err != nil {
		logger.WarnCF("qq", "Failed to detect audio duration, sending as file", map[string]any{
			"ref":      part.Ref,
			"filename": part.Filename,
			"error":    err.Error(),
		})
		return "file"
	}
	if !ok {
		logger.InfoCF("qq", "Sending audio as file because duration is unavailable", map[string]any{
			"ref":      part.Ref,
			"filename": part.Filename,
		})
		return "file"
	}
	if duration > qqVoiceMaxDuration {
		logger.InfoCF("qq", "Sending audio as file because it exceeds QQ voice limit", map[string]any{
			"ref":              part.Ref,
			"filename":         part.Filename,
			"duration_seconds": duration.Seconds(),
			"limit_seconds":    qqVoiceMaxDuration.Seconds(),
		})
		return "file"
	}

	return "audio"
}

func (c *QQChannel) sendUploadedMedia(
	ctx context.Context,
	chatKind, chatID string,
	part bus.MediaPart,
	fileInfo []byte,
) (*dto.Message, error) {
	msg := &dto.MessageToCreate{
		Content: part.Caption,
		MsgType: dto.RichMediaMsg,
		Media: &dto.MediaInfo{
			FileInfo: fileInfo,
		},
	}
	c.applyPassiveReplyMetadata(chatID, msg)

	if chatKind == "group" && msg.Content != "" {
		msg.Content = sanitizeURLs(msg.Content)
	}

	if chatKind == "group" {
		sentMsg, err := c.api.PostGroupMessage(ctx, chatID, msg)
		return sentMsg, err
	}
	sentMsg, err := c.api.PostC2CMessage(ctx, chatID, msg)
	return sentMsg, err
}

func (c *QQChannel) applyPassiveReplyMetadata(chatID string, msg *dto.MessageToCreate) {
	if v, ok := c.lastMsgID.Load(chatID); ok {
		if msgID, ok := v.(string); ok && msgID != "" {
			msg.MsgID = msgID

			// Increment msg_seq atomically for multi-part replies.
			if counterVal, ok := c.msgSeqCounters.Load(chatID); ok {
				if counter, ok := counterVal.(*atomic.Uint64); ok {
					seq := counter.Add(1)
					msg.MsgSeq = uint32(seq)
				}
			}
		}
	}
}

func (c *QQChannel) mediaUploadURL(chatKind, chatID string) string {
	base := constant.APIDomain
	if chatKind == "group" {
		return fmt.Sprintf("%s/v2/groups/%s/files", base, chatID)
	}
	return fmt.Sprintf("%s/v2/users/%s/files", base, chatID)
}

func qqFileType(partType string) uint64 {
	switch partType {
	case "image":
		return 1
	case "video":
		return 2
	case "audio":
		return 3
	default:
		return 4
	}
}

func (c *QQChannel) maxBase64FileSizeBytes() int64 {
	if c.config == nil {
		return 0
	}
	if c.config.MaxBase64FileSizeMiB <= 0 {
		return 0
	}
	return c.config.MaxBase64FileSizeMiB * bytesPerMiB
}

func (c *QQChannel) accountID() string {
	if c.config == nil {
		return ""
	}
	return c.config.AppID
}

// handleC2CMessage handles QQ private messages.
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
	return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
		// deduplication check
		if c.isDuplicate(data.ID) {
			return nil
		}

		// extract user info
		var senderID string
		if data.Author != nil && data.Author.ID != "" {
			senderID = data.Author.ID
		} else {
			logger.WarnC("qq", "Received message with no sender ID")
			return nil
		}

		sender := bus.SenderInfo{
			Platform:    "qq",
			PlatformID:  data.Author.ID,
			CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
		}

		if !c.IsAllowedSender(sender) {
			return nil
		}

		content := strings.TrimSpace(data.Content)
		mediaPaths, attachmentNotes := c.extractInboundAttachments(senderID, data.ID, data.Attachments)
		for _, note := range attachmentNotes {
			content = appendContent(content, note)
		}
		if content == "" && len(mediaPaths) == 0 {
			logger.DebugC("qq", "Received empty C2C message with no attachments, ignoring")
			return nil
		}

		logger.InfoCF("qq", "Received C2C message", map[string]any{
			"sender":      senderID,
			"length":      len(content),
			"media_count": len(mediaPaths),
		})

		// Store chat routing context.
		c.chatType.Store(senderID, "direct")
		c.lastMsgID.Store(senderID, data.ID)

		// Reset msg_seq counter for new inbound message.
		c.msgSeqCounters.Store(senderID, new(atomic.Uint64))

		metadata := map[string]string{
			"account_id": senderID,
		}
		inboundCtx := bus.InboundContext{
			Channel:   c.Name(),
			Account:   c.accountID(),
			ChatID:    senderID,
			ChatType:  "direct",
			SenderID:  senderID,
			MessageID: data.ID,
			Raw:       metadata,
		}

		c.HandleInboundContext(c.ctx, senderID, content, mediaPaths, inboundCtx, sender)

		return nil
	}
}

// handleGroupATMessage handles QQ group @ messages.
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
	return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
		// deduplication check
		if c.isDuplicate(data.ID) {
			return nil
		}

		// extract user info
		var senderID string
		if data.Author != nil && data.Author.ID != "" {
			senderID = data.Author.ID
		} else {
			logger.WarnC("qq", "Received group message with no sender ID")
			return nil
		}

		sender := bus.SenderInfo{
			Platform:    "qq",
			PlatformID:  data.Author.ID,
			CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID),
		}

		if !c.IsAllowedSender(sender) {
			return nil
		}

		content := strings.TrimSpace(data.Content)
		mediaPaths, attachmentNotes := c.extractInboundAttachments(data.GroupID, data.ID, data.Attachments)
		for _, note := range attachmentNotes {
			content = appendContent(content, note)
		}

		// GroupAT event means bot is always mentioned; apply group trigger filtering.
		respond, cleaned := c.ShouldRespondInGroup(true, content)
		if !respond {
			return nil
		}
		content = cleaned
		if content == "" && len(mediaPaths) == 0 {
			logger.DebugC("qq", "Received empty group message with no attachments, ignoring")
			return nil
		}

		logger.InfoCF("qq", "Received group AT message", map[string]any{
			"sender":      senderID,
			"group":       data.GroupID,
			"length":      len(content),
			"media_count": len(mediaPaths),
		})

		// Store chat routing context using GroupID as chatID.
		c.chatType.Store(data.GroupID, "group")
		c.lastMsgID.Store(data.GroupID, data.ID)

		// Reset msg_seq counter for new inbound message.
		c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))

		metadata := map[string]string{
			"account_id": senderID,
			"group_id":   data.GroupID,
		}
		inboundCtx := bus.InboundContext{
			Channel:   c.Name(),
			Account:   c.accountID(),
			ChatID:    data.GroupID,
			ChatType:  "group",
			SenderID:  senderID,
			MessageID: data.ID,
			Mentioned: true,
			Raw:       metadata,
		}

		c.HandleInboundContext(c.ctx, data.GroupID, content, mediaPaths, inboundCtx, sender)

		return nil
	}
}

func (c *QQChannel) extractInboundAttachments(
	chatID, messageID string,
	attachments []*dto.MessageAttachment,
) ([]string, []string) {
	if len(attachments) == 0 {
		return nil, nil
	}

	scope := channels.BuildMediaScope("qq", chatID, messageID)
	mediaPaths := make([]string, 0, len(attachments))
	notes := make([]string, 0, len(attachments))

	storeMedia := func(localPath string, attachment *dto.MessageAttachment) string {
		if store := c.GetMediaStore(); store != nil {
			ref, err := store.Store(localPath, media.MediaMeta{
				Filename:      qqAttachmentFilename(attachment),
				ContentType:   attachment.ContentType,
				Source:        "qq",
				CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
			}, scope)
			if err == nil {
				return ref
			}
		}
		return localPath
	}

	for _, attachment := range attachments {
		if attachment == nil {
			continue
		}

		filename := qqAttachmentFilename(attachment)
		if localPath := c.downloadAttachment(attachment.URL, filename); localPath != "" {
			mediaPaths = append(mediaPaths, storeMedia(localPath, attachment))
		} else if attachment.URL != "" {
			mediaPaths = append(mediaPaths, attachment.URL)
		}

		notes = append(notes, qqAttachmentNote(attachment))
	}

	return mediaPaths, notes
}

func (c *QQChannel) downloadAttachment(urlStr, filename string) string {
	if urlStr == "" {
		return ""
	}
	if c.downloadFn != nil {
		return c.downloadFn(urlStr, filename)
	}

	return utils.DownloadFile(urlStr, filename, utils.DownloadOptions{
		LoggerPrefix: "qq",
		ExtraHeaders: c.downloadHeaders(),
	})
}

func (c *QQChannel) downloadHeaders() map[string]string {
	headers := map[string]string{}

	if c.config.AppID != "" {
		headers["X-Union-Appid"] = c.config.AppID
	}

	if c.tokenSource != nil {
		if tk, err := c.tokenSource.Token(); err == nil && tk.AccessToken != "" {
			auth := strings.TrimSpace(tk.TokenType + " " + tk.AccessToken)
			if auth != "" {
				headers["Authorization"] = auth
			}
		}
	}

	if len(headers) == 0 {
		return nil
	}
	return headers
}

func qqAttachmentFilename(attachment *dto.MessageAttachment) string {
	if attachment == nil {
		return "attachment"
	}
	if attachment.FileName != "" {
		return attachment.FileName
	}
	if attachment.URL != "" {
		if parsed, err := url.Parse(attachment.URL); err == nil {
			if base := path.Base(parsed.Path); base != "" && base != "." && base != "/" {
				return base
			}
		}
	}

	switch qqAttachmentKind(attachment) {
	case "image":
		return "image"
	case "audio":
		return "audio"
	case "video":
		return "video"
	default:
		return "attachment"
	}
}

func qqAttachmentKind(attachment *dto.MessageAttachment) string {
	if attachment == nil {
		return "file"
	}

	contentType := strings.ToLower(attachment.ContentType)
	filename := strings.ToLower(attachment.FileName)

	switch {
	case strings.HasPrefix(contentType, "image/"):
		return "image"
	case strings.HasPrefix(contentType, "video/"):
		return "video"
	case strings.HasPrefix(contentType, "audio/"), contentType == "application/ogg", contentType == "application/x-ogg":
		return "audio"
	}

	switch filepath.Ext(filename) {
	case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
		return "image"
	case ".mp4", ".avi", ".mov", ".webm", ".mkv":
		return "video"
	case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus", ".silk":
		return "audio"
	default:
		return "file"
	}
}

func qqAttachmentNote(attachment *dto.MessageAttachment) string {
	filename := qqAttachmentFilename(attachment)

	switch qqAttachmentKind(attachment) {
	case "image":
		return fmt.Sprintf("[image: %s]", filename)
	case "audio":
		return fmt.Sprintf("[audio: %s]", filename)
	case "video":
		return fmt.Sprintf("[video: %s]", filename)
	default:
		return fmt.Sprintf("[file: %s]", filename)
	}
}

// isDuplicate checks whether a message has been seen within the TTL window.
// It also enforces a hard cap on map size by evicting oldest entries.
func (c *QQChannel) isDuplicate(messageID string) bool {
	c.muDedup.Lock()
	defer c.muDedup.Unlock()

	if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
		return true
	}

	// Enforce hard cap: evict oldest entries when at capacity.
	if len(c.dedup) >= dedupMaxSize {
		var oldestID string
		var oldestTS time.Time
		for id, ts := range c.dedup {
			if oldestID == "" || ts.Before(oldestTS) {
				oldestID = id
				oldestTS = ts
			}
		}
		if oldestID != "" {
			delete(c.dedup, oldestID)
		}
	}

	c.dedup[messageID] = time.Now()
	return false
}

// dedupJanitor periodically evicts expired entries from the dedup map.
func (c *QQChannel) dedupJanitor() {
	ticker := time.NewTicker(dedupInterval)
	defer ticker.Stop()

	for {
		select {
		case <-c.done:
			return
		case <-ticker.C:
			// Collect expired keys under read-like scan.
			c.muDedup.Lock()
			now := time.Now()
			var expired []string
			for id, ts := range c.dedup {
				if now.Sub(ts) >= dedupTTL {
					expired = append(expired, id)
				}
			}
			for _, id := range expired {
				delete(c.dedup, id)
			}
			c.muDedup.Unlock()
		}
	}
}

// isHTTPURL returns true if s starts with http:// or https://.
func isHTTPURL(s string) bool {
	return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}

func appendContent(content, suffix string) string {
	if suffix == "" {
		return content
	}
	if content == "" {
		return suffix
	}
	return content + "\n" + suffix
}

// urlPattern matches URLs with explicit http(s):// scheme.
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
// like version numbers (e.g., "1.2.3") or domain-like fragments.
var urlPattern = regexp.MustCompile(
	`(?i)` +
		`https?://` + // required scheme
		`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts
		`[a-zA-Z]{2,}` + // TLD
		`(?:[/?#]\S*)?`, // optional path/query/fragment
)

// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period)
// to prevent QQ's URL blacklist from rejecting the message.
func sanitizeURLs(text string) string {
	return urlPattern.ReplaceAllStringFunc(text, func(match string) string {
		// Split into scheme + rest (scheme is always present).
		idx := strings.Index(match, "://")
		scheme := match[:idx+3]
		rest := match[idx+3:]

		// Find where the domain ends (first / ? or #).
		domainEnd := len(rest)
		for i, ch := range rest {
			if ch == '/' || ch == '?' || ch == '#' {
				domainEnd = i
				break
			}
		}

		domain := rest[:domainEnd]
		path := rest[domainEnd:]

		// Replace dots in domain only.
		domain = strings.ReplaceAll(domain, ".", "。")

		return scheme + domain + path
	})
}

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