package qq

import (
	"context"
	"encoding/json"
	"fmt"
	"html"
	"log"
	"net/http"
	"regexp"
	"strings"
	"sync"
	"time"

	"github.com/gorilla/websocket"

	"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool { return true },
}

// Adapter implements PlatformAdapter for QQ via OneBot v11 WebSocket.
// Supports two modes:
//   - "server" (正向 WS): adapter starts a WS server, NapCat connects as client.
//   - "client" (反向 WS): adapter connects to NapCat's WS server as a client.
type Adapter struct {
	configID       string // immutable unique ID from PlatformConfig
	configName     string // instance name, e.g. "qq-home"
	mode           string // "client" or "server"
	port           string
	accessToken    string
	remoteURL      string // NapCat OneBot WS server URL, used in client mode
	httpURL        string // NapCat HTTP API base URL (optional, derived from remoteURL if empty)
	httpToken_     string // NapCat HTTP API access token (optional, uses accessToken if empty)
	sendIntervalMs int       // minimum interval between consecutive messages
	lastTypingSent time.Time // last time typing indicator was sent, for min display duration
	selfID         string    // bot's own QQ number, populated from incoming messages
	conn           *websocket.Conn
	connMu         sync.Mutex
	connected      bool
	connectedAt    time.Time // when the connection was established (for historical message detection)
	srv            *http.Server // HTTP server for WS upgrades (server mode only)

	groupNames   map[int64]string    // group ID → group name cache
	groupNamesMu sync.RWMutex

	pendingResponses map[string]chan *OBv11APIResponse
	respMu           sync.Mutex
}

func NewAdapter(configID, configName, mode, port, accessToken, remoteURL string, sendIntervalMs int) *Adapter {
	if mode == "" {
		mode = "server"
	}
	return &Adapter{
		configID:         configID,
		configName:       configName,
		mode:             mode,
		port:             port,
		accessToken:      accessToken,
		remoteURL:        remoteURL,
		sendIntervalMs:   sendIntervalMs,
		pendingResponses: make(map[string]chan *OBv11APIResponse),
		groupNames:       make(map[int64]string),
	}
}

// SetHTTPConfig sets the optional HTTP API configuration.
func (a *Adapter) SetHTTPConfig(url, token string) {
	a.httpURL = url
	if token != "" {
		a.httpToken_ = token
	}
}

func (a *Adapter) PlatformName() string   { return "obv11" }
func (a *Adapter) ConfigID() string      { return a.configID }
func (a *Adapter) ConfigName() string    { return a.configName }
func (a *Adapter) SendIntervalMs() int   { return a.sendIntervalMs }
func (a *Adapter) SelfID() string        { return a.selfID }

// ConnectedAt returns the time the connection was established, for historical message detection.
func (a *Adapter) ConnectedAt() time.Time {
	a.connMu.Lock()
	defer a.connMu.Unlock()
	return a.connectedAt
}

// GroupName resolves a group ID to its name. Returns empty string if unknown.
func (a *Adapter) GroupName(groupID int64) string {
	a.groupNamesMu.RLock()
	n, ok := a.groupNames[groupID]
	a.groupNamesMu.RUnlock()
	if ok {
		return n
	}
	// Try to resolve via API.
	a.fetchGroupName(groupID)
	return ""
}

// SetGroupName caches a group name for a group ID.
func (a *Adapter) SetGroupName(groupID int64, name string) {
	a.groupNamesMu.Lock()
	a.groupNames[groupID] = name
	a.groupNamesMu.Unlock()
}

// httpBase returns the HTTP API base URL.
// Uses configured httpURL if set, otherwise derives from WebSocket remoteURL.
func (a *Adapter) httpBase() string {
	if a.httpURL != "" {
		return a.httpURL
	}
	httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
	httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
	if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
		httpBase = httpBase[:idx]
	}
	return httpBase
}

// httpToken returns the HTTP API access token.
func (a *Adapter) httpToken() string {
	if a.httpToken_ != "" {
		return a.httpToken_
	}
	return a.accessToken
}

// SetTypingStatus sends a typing indicator to a private chat user.
// eventType: 1 = typing started, 0 = typing stopped.
// This uses the NapCat HTTP API (not standard OneBot v11).
// Enforces a minimum 3s display duration.
func (a *Adapter) SetTypingStatus(userID int64, eventType int) error {
	if eventType == 0 {
		// 确保"正在输入"至少显示了 3 秒
		if time.Since(a.lastTypingSent) < 3*time.Second {
			time.Sleep(3*time.Second - time.Since(a.lastTypingSent))
		}
	} else {
		a.lastTypingSent = time.Now()
	}
	url := a.httpBase() + "/set_input_status"
	if token := a.httpToken(); token != "" {
		url += "?access_token=" + token
	}
	body, _ := json.Marshal(map[string]interface{}{
		"user_id":    fmt.Sprintf("%d", userID),
		"event_type": eventType,
	})
	resp, err := http.Post(url, "application/json", strings.NewReader(string(body)))
	if err != nil {
		log.Printf("[qq] set_input_status error: %v (url=%s)", err, url)
		return fmt.Errorf("set_input_status: %w", err)
	}
	resp.Body.Close()
	if resp.StatusCode != 200 {
		log.Printf("[qq] set_input_status HTTP %d (url=%s)", resp.StatusCode, url)
		return fmt.Errorf("set_input_status: HTTP %d", resp.StatusCode)
	}
	return nil
}

// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
func (a *Adapter) fetchGroupName(groupID int64) {
	if a.mode != "client" || a.remoteURL == "" {
		return
	}
	httpBase := a.httpBase()

	go func() {
		url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
		if a.accessToken != "" {
			url += "&access_token=" + a.accessToken
		}
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
		if err != nil {
			return
		}
		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			return
		}
		defer resp.Body.Close()
		var result struct {
			Data struct {
				GroupName string `json:"group_name"`
			} `json:"data"`
		}
		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
			return
		}
		if result.Data.GroupName != "" {
			a.SetGroupName(groupID, result.Data.GroupName)
		}
	}()
}

func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
	return bridge.PlatformCapabilities{
		MaxMessageLength:   200,
		SupportsMarkdown:   true,
		SupportsImage:      true,
		SupportsVoice:      false,
		SupportsEmoji:      true,
		SupportsReaction:   false,
		SupportsTypingHint: false,
		RecommendBurstMax:  4,
	}
}

// checkAuth 验证 WebSocket 升级请求的 access_token。
// NapCat 通过两种方式传递: query 参数 ?access_token=xxx 或 Authorization: Bearer xxx 头.
func (a *Adapter) checkAuth(r *http.Request) bool {
	if a.accessToken == "" {
		return true // 未配置 token，允许所有连接
	}
	// 1) query 参数
	if r.URL.Query().Get("access_token") == a.accessToken {
		return true
	}
	// 2) Authorization: Bearer <token>
	auth := r.Header.Get("Authorization")
	if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == a.accessToken {
		return true
	}
	return false
}

// wsHandler 统一的 WebSocket 连接处理 — 单连接承载 API 调用 + 事件推送 (OneBot 正向 WS).
func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
	if !a.checkAuth(r) {
		http.Error(w, "Forbidden: invalid access_token", http.StatusForbidden)
		return
	}
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		fmt.Printf("[qq] upgrade error: %v\n", err)
		return
	}
	a.connMu.Lock()
	// 关闭旧连接 (NapCat 重连)
	if a.conn != nil {
		a.conn.Close()
	}
	a.conn = conn
	a.connected = true
	a.connectedAt = time.Now()
	a.connMu.Unlock()
	fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
}

// legacyHandler 兼容旧的路径 /ws/qq 和 /ws/qq/event.
func (a *Adapter) legacyHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Printf("[qq] legacy WS path %s connected (consider changing NapCat URL to root /)\n", r.URL.Path)
	a.wsHandler(w, r)
}

func (a *Adapter) Connect(ctx context.Context) error {
	if a.mode == "client" {
		return a.connectClient(ctx)
	}
	return a.connectServer()
}

func (a *Adapter) connectClient(ctx context.Context) error {
	url := a.remoteURL
	if a.accessToken != "" {
		sep := "?"
		if strings.Contains(url, "?") {
			sep = "&"
		}
		url += sep + "access_token=" + a.accessToken
	}

	dialer := websocket.DefaultDialer
	conn, _, err := dialer.DialContext(ctx, url, nil)
	if err != nil {
		return fmt.Errorf("dial NapCat WS %s: %w", url, err)
	}

	a.connMu.Lock()
	if a.conn != nil {
		a.conn.Close()
	}
	a.conn = conn
	a.connected = true
	a.connectedAt = time.Now()
	a.connMu.Unlock()

	fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
	return nil
}

func (a *Adapter) connectServer() error {
	mux := http.NewServeMux()
	mux.HandleFunc("/", a.wsHandler)                       // NapCat 正向 WS 标准路径
	mux.HandleFunc("/ws/qq", a.legacyHandler)               // 向下兼容旧配置
	mux.HandleFunc("/ws/qq/event", a.legacyHandler)         // 向下兼容旧配置

	addr := ":" + a.port
	a.srv = &http.Server{Addr: addr, Handler: mux}
	go func() {
		fmt.Printf("[qq] WebSocket server on %s (waiting for NapCat forward WS connection)\n", addr)
		if a.accessToken != "" {
			fmt.Println("[qq] access_token 已配置，将验证连接请求")
		}
		if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			fmt.Printf("[qq] server error: %v\n", err)
		}
	}()
	return nil
}

func (a *Adapter) Disconnect(ctx context.Context) error {
	a.connMu.Lock()
	if a.conn != nil {
		a.conn.Close()
		a.conn = nil
	}
	a.connected = false
	srv := a.srv
	a.srv = nil
	a.connMu.Unlock()

	if srv != nil {
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		defer cancel()
		if err := srv.Shutdown(shutdownCtx); err != nil {
			return fmt.Errorf("qq server shutdown: %w", err)
		}
	}
	return nil
}

func (a *Adapter) IsConnected() bool {
	a.connMu.Lock()
	defer a.connMu.Unlock()
	return a.connected
}

func (a *Adapter) HealthCheck() error {
	if !a.IsConnected() {
		return fmt.Errorf("QQ bot not connected")
	}
	return nil
}

// ToUnified converts an OBv11 message to UnifiedMessage.
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
	msg, ok := rawMessage.(*OBv11Message)
	if !ok {
		return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
	}

	// Handle notice events (e.g., poke/"戳一戳").
	if msg.PostType == "notice" {
		return a.noticeToUnified(msg)
	}

	content := extractText(msg)

	senderID := ""
	senderName := "unknown"
	channelType := "direct"
	channelID := ""

	switch msg.MessageType {
	case "private":
		senderID = fmt.Sprintf("%d", msg.UserID)
		channelType = "direct"
		channelID = fmt.Sprintf("private_%d", msg.UserID)
		if msg.Sender != nil {
			senderName = msg.Sender.Nickname
		}
	case "group":
		senderID = fmt.Sprintf("%d", msg.UserID)
		channelType = "group"
		channelID = fmt.Sprintf("%d", msg.GroupID)
		if msg.Sender != nil {
			if msg.Sender.Card != "" {
				senderName = msg.Sender.Card
			} else {
				senderName = msg.Sender.Nickname
			}
		}
	}

	var mentions []string
	var replyToText string
	if segments, ok := msg.Message.([]interface{}); ok {
		for _, s := range segments {
			if seg, ok := s.(map[string]interface{}); ok {
				if seg["type"] == "at" {
					if data, ok := seg["data"].(map[string]interface{}); ok {
						if qq, ok := data["qq"].(string); ok {
							mentions = append(mentions, qq)
						}
					}
				}
				if seg["type"] == "reply" {
					if data, ok := seg["data"].(map[string]interface{}); ok {
						if t, ok := data["text"].(string); ok && t != "" {
							replyToText = t
						}
						if id, ok := data["id"]; ok {
							_ = id // message ID of the replied-to message
						}
					}
				}
			}
		}
	}

	// Prepend reply context for the AI.
	if replyToText != "" {
		content = "【回复】" + truncateForReply(replyToText, 100) + "\n" + content
	}
	// Fallback: parse CQ at codes from string format (e.g. [CQ:at,qq=2254389756]).
	if len(mentions) == 0 {
		raw := msg.RawMessage
		if raw == "" {
			if s, ok := msg.Message.(string); ok {
				raw = s
			}
		}
		for _, m := range cqAtRegex.FindAllStringSubmatch(raw, -1) {
			if len(m) >= 2 {
				mentions = append(mentions, m[1])
			}
		}
	}

	// Resolve group name for group messages.
	groupName := ""
	if msg.MessageType == "group" {
		groupName = a.GroupName(msg.GroupID)
	}

	attachments := extractAttachments(msg)
	attachments = append(attachments, extractCardImageURLs(msg)...)

	return &bridge.UnifiedMessage{
		SenderID:    senderID,
		SenderName:  senderName,
		Platform:    "obv11",
		ChannelID:   channelID,
		ChannelType: channelType,
		Content:     content,
		ContentType: "text",
		MessageID:   fmt.Sprintf("%d", msg.MessageID),
		Mentions:    mentions,
		Attachments: attachments,
		RawData:     rawMessage,
		Timestamp:   time.Unix(msg.Time, 0),
			GroupName:   groupName,
	}, nil
}

// noticeToUnified converts an OBv11 notice event (poke/戳一戳 etc.) to a UnifiedMessage.
func (a *Adapter) noticeToUnified(msg *OBv11Message) (*bridge.UnifiedMessage, error) {
	senderID := fmt.Sprintf("%d", msg.UserID)
	senderName := senderID

	channelType := "direct"
	channelID := fmt.Sprintf("private_%d", msg.UserID)
	var groupName string

	if msg.GroupID != 0 {
		channelType = "group"
		channelID = fmt.Sprintf("%d", msg.GroupID)
		groupName = a.GroupName(msg.GroupID)
	}

	// Build a human-readable action description.
	// Group/private context is prepended by the message handler, so keep content to the action itself.
	var content string
	switch {
	case msg.SubType == "poke":
		detail := "戳了戳"
		if msg.PokeDetail != nil {
			if msg.PokeDetail.Action != "" {
				detail = msg.PokeDetail.Action
			}
			if msg.PokeDetail.Suffix != "" {
				detail += " " + msg.PokeDetail.Suffix
			}
		}
		content = fmt.Sprintf("【动作】%s 昔涟", detail)
	default:
		content = fmt.Sprintf("【动作】%s/%s", msg.NoticeType, msg.SubType)
	}

	return &bridge.UnifiedMessage{
		SenderID:    senderID,
		SenderName:  senderName,
		Platform:    "obv11",
		ChannelID:   channelID,
		ChannelType: channelType,
		Content:     content,
		ContentType: "action",
		MessageID:   fmt.Sprintf("notice_%d", time.Now().UnixNano()),
		GroupName:   groupName,
		RawData:     msg,
		Timestamp:   time.Unix(msg.Time, 0),
	}, nil
}

// FromUnified converts a unified response to QQ platform messages.
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
	var msgs []bridge.PlatformMessage
	for _, rm := range response.Messages {
		content := ConvertMarkdownToQQ(rm.Content)
		runes := []rune(content)
		if len(runes) > 200 {
			content = string(runes[:200])
		}
		msgs = append(msgs, bridge.PlatformMessage{
			Content:    content,
			ReplyTo:    response.ReplyTo,
			FormatMode: "text",
		})
	}
	return msgs, nil
}

// SendProactive implements bridge.ProactiveSender for proactive message delivery.
func (a *Adapter) SendProactive(chatType string, userID, groupID int64, content string) error {
	return a.SendMessage(chatType, userID, groupID, content)
}

// SendMessage sends a message through the QQ WebSocket connection.
func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error {
	a.connMu.Lock()
	conn := a.conn
	a.connMu.Unlock()
	if conn == nil {
		return fmt.Errorf("QQ bot not connected")
	}

	req := OBv11SendMsg{
		Action: "send_msg",
		Params: OBv11Params{
			MessageType: msgType,
			UserID:      userID,
			GroupID:     groupID,
			Message:     content,
			AutoEscape:  false,
		},
		Echo: fmt.Sprintf("echo_%d", time.Now().UnixNano()),
	}

	data, _ := json.Marshal(req)
	return conn.WriteMessage(websocket.TextMessage, data)
}

// ReadMessages reads OBv11 messages from the WebSocket connection.
func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) {
	for {
		select {
		case <-ctx.Done():
			return
		default:
		}

		a.connMu.Lock()
		conn := a.conn
		a.connMu.Unlock()
		if conn == nil {
			// Client mode: auto-reconnect when connection is lost.
			if a.mode == "client" {
				if err := a.connectClient(ctx); err != nil {
					fmt.Printf("[qq] reconnect failed: %v, retrying in 3s...\n", err)
					time.Sleep(3 * time.Second)
				}
				continue
			}
			time.Sleep(time.Second)
			continue
		}

		_, raw, err := conn.ReadMessage()
		if err != nil {
			fmt.Printf("[qq:%s] 连接断开: %v\n", err)
			a.connMu.Lock()
			if a.conn != nil {
				a.conn.Close()
				a.conn = nil
			}
			a.connected = false
			a.connMu.Unlock()
			time.Sleep(time.Second)
			continue
		}

		// Try to parse as OBv11 message (event from QQ).
		var msg OBv11Message
		if err := json.Unmarshal(raw, &msg); err != nil {
			var resp OBv11APIResponse
			if err := json.Unmarshal(raw, &resp); err != nil {
				fmt.Printf("[qq] unknown message: %s\n", string(raw))
				continue
			}
			if resp.Echo != "" {
				a.respMu.Lock()
				if ch, ok := a.pendingResponses[resp.Echo]; ok {
					ch <- &resp
					delete(a.pendingResponses, resp.Echo)
				}
				a.respMu.Unlock()
			}
			continue
		}

		// Capture bot's own QQ number from incoming messages.
		if msg.SelfID != 0 && a.selfID == "" {
			a.selfID = fmt.Sprintf("%d", msg.SelfID)
			fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
		}

		// Dispatch message and notice (poke, etc.) events.
		if msg.PostType == "message" || (msg.PostType == "notice" && msg.NoticeType == "notify" && msg.SubType == "poke") {
			select {
			case msgCh <- &msg:
			case <-ctx.Done():
				return
			}
		}
	}
}

// parseJSONCardTitle extracts human-readable content from a QQ JSON card.
func parseJSONCardTitle(data string) string {
	var card struct {
		App    string `json:"app"`
		Prompt string `json:"prompt"`
		Title  string `json:"title"`
		Desc   string `json:"desc"`
		Meta   struct {
			Detail1 struct {
				Title string `json:"title"`
				Desc  string `json:"desc"`
				Tag   string `json:"tag"`
			} `json:"detail_1"`
			News struct {
				Title   string `json:"title"`
				Desc    string `json:"desc"`
				Tag     string `json:"tag"`
				Preview string `json:"preview"`
			} `json:"news"`
			Music struct {
				Title   string `json:"title"`
				Desc    string `json:"desc"`
				Tag     string `json:"tag"`
				Preview string `json:"preview"`
			} `json:"music"`
		} `json:"meta"`
	}
	if err := json.Unmarshal([]byte(data), &card); err != nil {
		return parseJSONCardFallback(data)
	}

	var parts []string
	push := func(s string) { if s != "" { parts = append(parts, s) } }

	// Build header: [音乐分享] or [卡片]
	label := cardAppLabel(card.App)
	if card.Prompt != "" {
		// Strip redundant QQ-specific prefix from prompt (e.g. "[QQ小程序]" when we already say "[小程序]").
		prompt := card.Prompt
		for _, pfx := range qqPromptPrefixes {
			if strings.HasPrefix(prompt, pfx) {
				prompt = strings.TrimPrefix(prompt, pfx)
				break
			}
		}
		push(label + " " + prompt)
	} else if t := firstNonEmpty(card.Meta.Detail1.Title, card.Meta.News.Title, card.Meta.Music.Title, card.Title); t != "" {
		push(label + " " + t)
	}

	// Append description and source tag if available.
	desc := firstNonEmpty(card.Meta.Detail1.Desc, card.Meta.Music.Desc, card.Meta.News.Desc, card.Desc)
	if desc != "" && desc != card.Prompt && desc != card.Title &&
		(len(parts) == 0 || !strings.Contains(parts[0], desc)) {
		push("简介：" + desc)
	}
	tag := firstNonEmpty(card.Meta.Music.Tag, card.Meta.News.Tag, card.Meta.Detail1.Tag)
	if tag != "" && (len(parts) == 0 || !strings.Contains(parts[len(parts)-1], tag)) {
		push("来源：" + tag)
	}
	preview := firstNonEmpty(card.Meta.Music.Preview, card.Meta.News.Preview)
	if preview != "" {
		push("封面：" + preview)
	}

	if len(parts) == 0 {
		return parseJSONCardFallback(data)
	}
	return strings.Join(parts, "\n")
}

func firstNonEmpty(ss ...string) string {
	for _, s := range ss {
		if s != "" { return s }
	}
	return ""
}

// cardAppLabel maps QQ card app IDs to human-readable Chinese labels.
func cardAppLabel(app string) string {
	if label, ok := cardAppMap[app]; ok {
		return "[" + label + "]"
	}
	return "[卡片]"
}

var cardAppMap = map[string]string{
	"com.tencent.music.lua":       "音乐分享",
	"com.tencent.structmsg":       "结构化消息",
	"com.tencent.qun.pro":         "群Pro卡片",
	"com.tencent.miniapp_01":      "小程序",
	"com.tencent.contact.lua":     "联系人分享",
	"com.tencent.groupphoto":      "群相册",
	"com.tencent.qqfiles":         "文件分享",
	"com.tencent.announcement":    "群公告",
	"com.tencent.mobileqq.ark":    "ARK消息",
	"com.tencent.tuwen.lua":       "图文消息",
}

// QQ card prompts often include a self-describing prefix that overlaps with our label.
var qqPromptPrefixes = []string{
	"[QQ小程序] ", "[QQ小程序]",
	"[QQ音乐] ", "[QQ音乐]",
}

// parseJSONCardFallback tries a generic approach for unknown card structures.
func parseJSONCardFallback(data string) string {
	var raw map[string]interface{}
	if err := json.Unmarshal([]byte(data), &raw); err != nil {
		return "[卡片消息]"
	}

	var parts []string
	push := func(s string) { if s != "" { parts = append(parts, s) } }

	label := "[卡片]"
	if app, ok := raw["app"].(string); ok {
		label = cardAppLabel(app)
	}
	if s, _ := raw["prompt"].(string); s != "" {
		push(label + " " + s)
	} else if s, _ := raw["title"].(string); s != "" {
		push(label + " " + s)
	}

	// Search meta for title and desc.
	if meta, ok := raw["meta"].(map[string]interface{}); ok {
		for _, v := range meta {
			if sub, ok := v.(map[string]interface{}); ok {
				if s, _ := sub["title"].(string); s != "" && len(parts) == 0 {
					push(label + " " + s)
				}
			}
		}
		for _, v := range meta {
			if sub, ok := v.(map[string]interface{}); ok {
				if s, _ := sub["desc"].(string); s != "" {
					push("简介：" + s)
					break
				}
			}
		}
		for _, v := range meta {
			if sub, ok := v.(map[string]interface{}); ok {
				if s, _ := sub["tag"].(string); s != "" {
					push("来源：" + s)
					break
				}
			}
		}
	}
	if s, _ := raw["desc"].(string); s != "" && len(parts) < 2 {
		push("简介：" + s)
	}

	if len(parts) == 0 {
		log.Printf("[qq] 卡片解析失败，原始JSON: %s", data)
		return "[卡片消息]"
	}
	return strings.Join(parts, "\n")
}

// cqSimplifyMap maps CQ code types to simplified Chinese labels.
var cqSimplifyMap = map[string]string{
	"image":  "[图片]",
	"reply":  "[回复]",
	"face":   "[表情]",
	"record": "[语音]",
	"video":  "[视频]",
	"file":   "[文件]",
}

// simplifyCQCodes replaces [CQ:type,...] codes with human-readable labels.
func simplifyCQCodes(s string) string {
	return cqAllRegex.ReplaceAllStringFunc(s, func(match string) string {
		// match looks like "[CQ:image,file=xxx,url=xxx]"
		// Extract the type (text between "CQ:" and the first "," or "]").
		typ := match[4:] // strip "[CQ:"
		for i, c := range typ {
			if c == ',' || c == ']' {
				typ = typ[:i]
				break
			}
		}
		if typ == "json" {
			// Parse data= field from [CQ:json,data=HTML_ENCODED_JSON]
			if dataVal := extractCQParam(match, "data"); dataVal != "" {
				decoded := html.UnescapeString(dataVal)
				return parseJSONCardTitle(decoded)
			}
			return "[卡片消息]"
		}
		if label, ok := cqSimplifyMap[typ]; ok {
			return label
		}
		return "[" + typ + "]"
	})
}

// extractCQParam extracts a named parameter value from a CQ code string.
// e.g. extractCQParam("[CQ:json,data=hello%20world]", "data") → "hello%20world"
func extractCQParam(cqCode, paramName string) string {
	prefix := paramName + "="
	idx := strings.Index(cqCode, prefix)
	if idx < 0 {
		return ""
	}
	val := cqCode[idx+len(prefix):]
	// Value ends at "," or "]"
	for i, c := range val {
		if c == ',' || c == ']' {
			return val[:i]
		}
	}
	return val
}

// extractText retrieves plain text from an OBv11 message.
// CQ codes are converted to human-readable form where applicable (e.g. [CQ:at,qq=xxx] → @xxx).
func extractText(msg *OBv11Message) string {
	if msg.RawMessage != "" {
		s := cqAtRegex.ReplaceAllString(msg.RawMessage, "@$1")
		return simplifyCQCodes(s)
	}
	switch m := msg.Message.(type) {
	case string:
		s := cqAtRegex.ReplaceAllString(m, "@$1")
		return simplifyCQCodes(s)
	case []interface{}:
		var text string
		for _, seg := range m {
			if s, ok := seg.(map[string]interface{}); ok {
				switch s["type"] {
				case "text":
					if data, ok := s["data"].(map[string]interface{}); ok {
						if t, ok := data["text"].(string); ok {
							text += t
						}
					}
				case "at":
					if data, ok := s["data"].(map[string]interface{}); ok {
						if qq, ok := data["qq"].(string); ok {
							text += "@" + qq
						}
					}
				case "image":
					text += "[图片]"
				case "face":
					text += "[表情]"
				case "record":
					text += "[语音]"
				case "video":
					text += "[视频]"
				case "file":
					text += "[文件]"
				case "reply":
					// Reply is handled separately in ToUnified with reply text.
					text += "[回复]"
				case "json", "card":
					if data, ok := s["data"].(map[string]interface{}); ok {
						inner := data["data"]
						switch v := inner.(type) {
						case string:
							if v != "" {
								text += parseJSONCardTitle(v)
							} else {
								text += "[卡片消息]"
							}
						case map[string]interface{}:
							// Already parsed — re-marshal to JSON string for parsing.
							if b, err := json.Marshal(v); err == nil {
								text += parseJSONCardTitle(string(b))
							} else {
								text += "[卡片消息]"
							}
						default:
							text += "[卡片消息]"
						}
					} else {
						text += "[卡片消息]"
					}
				}
			}
		}
		return text
	}
	return ""
}

var cqAtRegex = regexp.MustCompile(`\[CQ:at,qq=(\d+)\]`)
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
var cqVideoRegex = regexp.MustCompile(`\[CQ:video,[^\]]*\]`)
var cqRecordRegex = regexp.MustCompile(`\[CQ:record,[^\]]*\]`)
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
var cqDurationRegex = regexp.MustCompile(`\bduration=(\d+)`)
var cqAllRegex = regexp.MustCompile(`\[CQ:[^\]]+\]`)
var cqJSONRegex = regexp.MustCompile(`\[CQ:(?:json|card),[^\]]*data=[^\]]*\]`)
	var boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
	var italicRegex = regexp.MustCompile(`\*(.+?)\*`)
	var strikethroughRegex = regexp.MustCompile(`~~(.+?)~~`)

func parseIntOr(s string, defaultVal int) int {
	if s == "" {
		return defaultVal
	}
	n := 0
	for _, c := range s {
		if c >= '0' && c <= '9' {
			n = n*10 + int(c-'0')
		} else {
			return defaultVal
		}
	}
	return n
}

// truncateForReply truncates reply preview text to keep messages readable.
func truncateForReply(s string, maxLen int) string {
	runes := []rune(s)
	if len(runes) <= maxLen {
		return s
	}
	return string(runes[:maxLen]) + "…"
}

// extractAttachments extracts image/video URLs from OBv11Message.
// Handles both string format (CQ codes in raw_message) and array format (parsed segments).
func extractAttachments(msg *OBv11Message) []bridge.Attachment {
	var attachments []bridge.Attachment

	// Array format: iterate segments looking for image and video.
	if segments, ok := msg.Message.([]interface{}); ok {
		for _, s := range segments {
			if seg, ok := s.(map[string]interface{}); ok {
				segType, _ := seg["type"].(string)
				if segType != "image" && segType != "video" && segType != "record" {
					continue
				}
				data, _ := seg["data"].(map[string]interface{})
				if data == nil {
					continue
				}
				url, _ := data["url"].(string)
				file, _ := data["file"].(string)
				if url == "" {
					continue
				}
				att := bridge.Attachment{
					Type:     segType,
					URL:      url,
					FileName: file,
				}
				if segType == "video" {
					if d, ok := data["duration"].(float64); ok {
						att.Duration = int(d)
					}
				}
				attachments = append(attachments, att)
			}
		}
		return attachments
	}

	// String format: parse CQ codes from RawMessage or string Message.
	raw := msg.RawMessage
	if raw == "" {
		if s, ok := msg.Message.(string); ok {
			raw = s
		}
	}
	// Images.
	for _, m := range cqImageRegex.FindAllString(raw, -1) {
		urlMatch := cqURLRegex.FindStringSubmatch(m)
		if len(urlMatch) >= 2 {
			attachments = append(attachments, bridge.Attachment{Type: "image", URL: urlMatch[1]})
		}
	}
	// Videos.
	for _, m := range cqVideoRegex.FindAllString(raw, -1) {
		urlMatch := cqURLRegex.FindStringSubmatch(m)
		if len(urlMatch) >= 2 {
			dur := 0
			if dm := cqDurationRegex.FindStringSubmatch(m); len(dm) >= 2 {
				dur = parseIntOr(dm[1], 0)
			}
			attachments = append(attachments, bridge.Attachment{Type: "video", URL: urlMatch[1], Duration: dur})
		}
	}
	// Records (voice messages).
	for _, m := range cqRecordRegex.FindAllString(raw, -1) {
		urlMatch := cqURLRegex.FindStringSubmatch(m)
		if len(urlMatch) >= 2 {
			attachments = append(attachments, bridge.Attachment{Type: "voice", URL: urlMatch[1]})
		}
	}
	return attachments
}

// extractCardImageURLs finds json/card CQ codes in the raw message and extracts
// preview/cover image URLs so they can be fed to the vision pipeline.
func extractCardImageURLs(msg *OBv11Message) []bridge.Attachment {
	raw := msg.RawMessage
	if raw == "" {
		if s, ok := msg.Message.(string); ok {
			raw = s
		}
	}
	// Match [CQ:json,...] or [CQ:card,...]
	matches := cqJSONRegex.FindAllString(raw, -1)
	if len(matches) == 0 {
		return nil
	}

	var attachments []bridge.Attachment
	seen := map[string]bool{}
	for _, m := range matches {
		dataVal := extractCQParam(m, "data")
		if dataVal == "" {
			continue
		}
		decoded := html.UnescapeString(dataVal)
		urls := extractPreviewURLs(decoded)
		for _, u := range urls {
			if !seen[u] {
				seen[u] = true
				attachments = append(attachments, bridge.Attachment{Type: "image", URL: u})
			}
		}
	}
	return attachments
}

// extractPreviewURLs parses a QQ card JSON and returns any preview/cover image URLs.
func extractPreviewURLs(data string) []string {
	var card struct {
		Meta struct {
			Music struct {
				Preview string `json:"preview"`
			} `json:"music"`
			News struct {
				Preview string `json:"preview"`
			} `json:"news"`
		} `json:"meta"`
		// Some card types have preview at top level or in other locations.
		Preview string `json:"preview"`
	}
	if err := json.Unmarshal([]byte(data), &card); err != nil {
		return nil
	}
	var urls []string
	if card.Meta.Music.Preview != "" {
		urls = append(urls, card.Meta.Music.Preview)
	}
	if card.Meta.News.Preview != "" {
		urls = append(urls, card.Meta.News.Preview)
	}
	if card.Preview != "" {
		urls = append(urls, card.Preview)
	}
	return urls
}

// ConvertMarkdownToQQ converts markdown to QQ plain-text format.
func ConvertMarkdownToQQ(md string) string {
	md = boldRegex.ReplaceAllString(md, "$1")
	md = italicRegex.ReplaceAllString(md, "$1")
	md = strikethroughRegex.ReplaceAllString(md, "$1")
	md = removeHeadings(md)
	md = removeCodeBlocks(md)
	return md
}

func removeHeadings(s string) string {
	for _, line := range splitLines(s) {
		if len(line) > 0 && line[0] == '#' {
			s = replaceLine(s, line, stripPrefix(line, "# "))
		}
	}
	return s
}

func removeCodeBlocks(s string) string {
	var kept []string
	inCode := false
	for _, line := range splitLines(s) {
		if hasPrefix(line, "```") {
			inCode = !inCode
			continue
		}
		kept = append(kept, line)
	}
	_ = inCode
	return strings.Join(kept, "\n")
}

func splitLines(s string) []string {
	var lines []string
	start := 0
	for i, c := range s {
		if c == '\n' {
			lines = append(lines, s[start:i])
			start = i + 1
		}
	}
	if start < len(s) {
		lines = append(lines, s[start:])
	}
	return lines
}

func hasPrefix(s, prefix string) bool {
	return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}

func stripPrefix(s, prefix string) string {
	if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
		return s[len(prefix):]
	}
	return s
}

func replaceLine(s, old, new string) string {
	idx := indexOf(s, old)
	if idx < 0 {
		return s
	}
	return s[:idx] + new + s[idx+len(old):]
}

func indexOf(s, sub string) int {
	for i := 0; i <= len(s)-len(sub); i++ {
		if s[i:i+len(sub)] == sub {
			return i
		}
	}
	return -1
}
