package qq

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"reasonix/internal/bot"
	"reasonix/internal/textutil"

	"golang.org/x/net/websocket"
)

const (
	qqTokenURL                     = "https://bots.qq.com/app/getAppAccessToken"
	qqBaseURL                      = "https://api.sgroup.qq.com"
	qqSandboxURL                   = "https://sandbox.api.sgroup.qq.com"
	qqGatewayURL                   = "wss://api.sgroup.qq.com/websocket"
	qqMaxChunkBytes                = 1500
	qqMaxPassiveReplyChunks        = 5
	qqMinHeartbeat                 = 5 * time.Second
	qqMaxHeartbeat                 = time.Minute
	qqStartupValidationTimeout     = 10 * time.Second
	qqPassiveReplyTruncationNotice = "\n\n[Truncated: QQ allows at most 5 passive replies for one incoming message.]"
	qqHTTPTimeout                  = 30 * time.Second

	opDispatch     = 0
	opHeartbeat    = 1
	opIdentify     = 2
	opResume       = 6
	opReconnect    = 7
	opInvalid      = 9
	opHello        = 10
	opHeartbeatAck = 11
)

var qqMarkdownWrapperRe = regexp.MustCompile("(?is)^```(?:markdown|md)\\s*\\r?\\n([\\s\\S]*?)\\r?\\n```$")

var qqHTTPClient = &http.Client{Timeout: qqHTTPTimeout}

var allowedGatewayHosts = []string{
	"api.sgroup.qq.com",
	"sandbox.api.sgroup.qq.com",
	"qq.com",
}

// gatewayPayload QQ WebSocket 消息载荷。
type gatewayPayload struct {
	Op int             `json:"op"`
	D  json.RawMessage `json:"d,omitempty"`
	S  int64           `json:"s,omitempty"`
	T  string          `json:"t,omitempty"`
}

type helloData struct {
	HeartbeatInterval int `json:"heartbeat_interval"`
}

type identifyData struct {
	Token      string     `json:"token"`
	Intents    int        `json:"intents"`
	Shard      [2]int     `json:"shard"`
	Properties properties `json:"properties"`
}

type properties struct {
	OS      string `json:"$os"`
	Browser string `json:"$browser"`
	Device  string `json:"$device"`
}

type dispatchEvent struct {
	ID        string `json:"id"`
	Type      string `json:"type"`
	Content   string `json:"content"`
	Timestamp string `json:"timestamp"`
	Author    struct {
		ID           string `json:"id"`
		UserOpenID   string `json:"user_openid"`
		MemberOpenID string `json:"member_openid"`
		UnionOpenID  string `json:"union_openid"`
		Username     string `json:"username"`
	} `json:"author"`
	ChannelID   string `json:"channel_id"`
	GuildID     string `json:"guild_id"`
	GroupOpenID string `json:"group_openid"`
}

// wsClient 管理 QQ WebSocket 连接。
type wsClient struct {
	mu          sync.Mutex
	conn        *websocket.Conn
	heartbeatMs int
	sessionID   string
	lastSeq     int64
	token       string
	logger      *slog.Logger
}

func (a *adapter) gatewayLoop(ctx context.Context) {
	selectedIntents := qqPrivateIdentifyIntents
	bot.RunWithRetry(ctx, a.logger, "qq gateway", bot.RetryConfig{}, func(ctx context.Context) error {
		token, err := a.getAccessToken(ctx)
		if err != nil {
			return err
		}
		// connectGateway blocks for the connection's lifetime, returning on
		// disconnect or error; RunWithRetry handles the cancellation-aware
		// backoff and reconnect.
		_, err = connectQQGatewayWithIntentFallback(ctx, token, &selectedIntents, a.connectGateway, func() {
			a.logger.Warn("qq private-guild intent rejected; retrying with public-guild events")
		})
		return err
	})
}

func (a *adapter) getAccessToken(ctx context.Context) (string, error) {
	a.tokenMu.Lock()
	if a.token != "" && time.Now().Before(a.tokenExpiry) {
		token := a.token
		a.tokenMu.Unlock()
		return token, nil
	}
	a.tokenMu.Unlock()

	appID := a.appID()
	appSecret := a.appSecret()
	if appID == "" {
		return "", fmt.Errorf("qq app_id is empty")
	}
	if appSecret == "" {
		return "", fmt.Errorf("qq app secret is empty: set the %s environment variable", a.appSecretEnvName())
	}
	body, err := json.Marshal(map[string]string{
		"appId":        appID,
		"clientSecret": appSecret,
	})
	if err != nil {
		return "", err
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, qqTokenURL, bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := qqHTTPClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("qq token api error %d: %s", resp.StatusCode, string(respBody))
	}

	var result struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"-"`
		ExpiresRaw  any    `json:"expires_in"`
	}
	if err := json.Unmarshal(respBody, &result); err != nil {
		return "", err
	}
	result.ExpiresIn, err = qqExpiresInSeconds(result.ExpiresRaw)
	if err != nil {
		return "", err
	}
	if result.AccessToken == "" {
		return "", fmt.Errorf("empty access token")
	}
	a.tokenMu.Lock()
	a.token = result.AccessToken
	expiresIn := int(result.ExpiresIn)
	if expiresIn > 60 {
		a.tokenExpiry = time.Now().Add(time.Duration(expiresIn-60) * time.Second)
	} else {
		a.tokenExpiry = time.Now().Add(5 * time.Minute)
	}
	a.tokenMu.Unlock()
	a.logger.Info("qq access token acquired", "expires_in_seconds", result.ExpiresIn)
	return result.AccessToken, nil
}

func qqExpiresInSeconds(value any) (int, error) {
	switch v := value.(type) {
	case nil:
		return 0, nil
	case float64:
		return int(v), nil
	case string:
		trimmed := strings.TrimSpace(v)
		if trimmed == "" {
			return 0, nil
		}
		n, err := strconv.Atoi(trimmed)
		if err != nil {
			return 0, fmt.Errorf("invalid qq token expires_in %q: %w", v, err)
		}
		return n, nil
	default:
		return 0, fmt.Errorf("invalid qq token expires_in type %T", value)
	}
}

func (a *adapter) connectGateway(ctx context.Context, token string, intents int) error {
	gatewayURL, err := a.getGatewayURL(ctx, token)
	if err != nil {
		return err
	}
	if parsed, parseErr := url.Parse(gatewayURL); parseErr == nil {
		a.logger.Info("qq gateway endpoint resolved", "host", parsed.Hostname(), "sandbox", a.cfg.Sandbox)
	}
	conn, err := a.dialGateway(ctx, gatewayURL, token)
	if err != nil {
		return fmt.Errorf("dial gateway: %w", err)
	}
	defer conn.Close()
	defer a.dropConn(conn)
	if !a.trackConn(ctx, conn) {
		// Stop already closed the tracked conn slot; entering a blocking read
		// now would leave a connection Stop can no longer unblock.
		return ctx.Err()
	}
	a.logger.Info("qq gateway connected", "sandbox", a.cfg.Sandbox)

	ws := &wsClient{conn: conn, token: token, logger: a.logger}

	var msg gatewayPayload
	decoder := json.NewDecoder(conn)

	// 第一次读取必须是 Hello
	if err := decoder.Decode(&msg); err != nil {
		return fmt.Errorf("read hello: %w", err)
	}
	if msg.Op != opHello {
		return fmt.Errorf("expected op=%d hello, got op=%d", opHello, msg.Op)
	}
	var hello helloData
	if err := json.Unmarshal(msg.D, &hello); err != nil {
		return err
	}
	ws.heartbeatMs = int(sanitizeHeartbeatInterval(time.Duration(hello.HeartbeatInterval) * time.Millisecond).Milliseconds())

	// Identify
	identify := identifyData{
		Token:   fmt.Sprintf("QQBot %s", token),
		Intents: intents,
		Shard:   [2]int{0, 1},
		Properties: properties{
			OS:      "linux",
			Browser: "reasonix",
			Device:  "reasonix-bot",
		},
	}
	identifyJSON, _ := json.Marshal(identify)
	if err := ws.send(opIdentify, identifyJSON); err != nil {
		return fmt.Errorf("send identify: %w", err)
	}

	// 读取 Ready
	if err := decoder.Decode(&msg); err != nil {
		return fmt.Errorf("read ready: %w", err)
	}
	if err := validateQQReadyPayload(msg); err != nil {
		return err
	}
	var ready struct {
		SessionID string `json:"session_id"`
	}
	if err := json.Unmarshal(msg.D, &ready); err != nil {
		return fmt.Errorf("decode ready: %w", err)
	}
	ws.sessionID = ready.SessionID
	a.sessionID = ready.SessionID
	a.seq = msg.S
	a.logger.Info("qq gateway ready", "sandbox", a.cfg.Sandbox, "heartbeat_ms", ws.heartbeatMs)

	// 启动 heartbeat
	heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
	defer heartbeatCancel()
	heartbeatDone := make(chan struct{})
	go func() {
		defer close(heartbeatDone)
		ticker := time.NewTicker(time.Duration(ws.heartbeatMs) * time.Millisecond)
		defer ticker.Stop()
		for {
			select {
			case <-heartbeatCtx.Done():
				return
			case <-ticker.C:
				ws.mu.Lock()
				payload := json.RawMessage("null")
				if ws.lastSeq != 0 {
					payload = json.RawMessage(fmt.Sprintf(`%d`, ws.lastSeq))
				}
				if err := ws.send(opHeartbeat, payload); err != nil {
					ws.logger.Error("heartbeat failed", "err", err)
					ws.mu.Unlock()
					return
				}
				ws.mu.Unlock()
			}
		}
	}()

	// 主循环：读取 dispatch 事件
	for {
		if err := decoder.Decode(&msg); err != nil {
			a.logger.Error("decode gateway message", "err", err)
			heartbeatCancel()
			<-heartbeatDone
			return err
		}
		ws.lastSeq = msg.S
		a.seq = msg.S

		switch msg.Op {
		case opDispatch:
			a.handleDispatch(msg)
		case opHeartbeatAck:
		case opReconnect:
			a.logger.Info("gateway requested reconnect")
			heartbeatCancel()
			<-heartbeatDone
			return nil
		case opInvalid:
			a.sessionID = ""
			a.seq = 0
			a.logger.Info("gateway session invalidated")
			heartbeatCancel()
			<-heartbeatDone
			return nil
		}
	}
}

// dialGateway dials the QQ gateway honoring ctx. The conn only becomes
// trackable after the dial returns, so Stop can interrupt a stalled TCP dial
// or WebSocket/TLS handshake only through ctx cancellation —
// websocket.DialConfig would dial with context.Background() and leave Stop's
// loopWG.Wait blocked with nothing to close.
func (a *adapter) dialGateway(ctx context.Context, gatewayURL, token string) (*websocket.Conn, error) {
	cfg, err := websocket.NewConfig(gatewayURL, gatewayURL)
	if err != nil {
		return nil, err
	}
	cfg.Header = http.Header{}
	cfg.Header.Set("Authorization", "QQBot "+token)
	cfg.Header.Set("X-Union-Appid", a.appID())
	return cfg.DialContext(ctx)
}

// trackConn publishes the live gateway connection so Stop can close it and
// unblock the blocking websocket reads, which do not honor ctx. Publication is
// refused once ctx is cancelled, so a conn that finishes dialing concurrently
// with Stop can never be left open but unreachable.
func (a *adapter) trackConn(ctx context.Context, conn *websocket.Conn) bool {
	a.connMu.Lock()
	defer a.connMu.Unlock()
	if ctx.Err() != nil {
		return false
	}
	a.conn = conn
	return true
}

func (a *adapter) dropConn(conn *websocket.Conn) {
	a.connMu.Lock()
	if a.conn == conn {
		a.conn = nil
	}
	a.connMu.Unlock()
}

func (a *adapter) closeConn() {
	a.connMu.Lock()
	conn := a.conn
	a.conn = nil
	a.connMu.Unlock()
	if conn != nil {
		conn.Close()
	}
}

func (a *adapter) appID() string {
	if value := strings.TrimSpace(a.cfg.AppID); value != "" {
		return value
	}
	return strings.TrimSpace(os.Getenv("QQ_APPID"))
}

func (a *adapter) appSecretEnvName() string {
	if value := strings.TrimSpace(a.cfg.AppSecretEnv); value != "" {
		return value
	}
	return "QQ_BOT_APP_SECRET"
}

func (a *adapter) appSecret() string {
	return strings.TrimSpace(os.Getenv(a.appSecretEnvName()))
}

func (a *adapter) apiBaseURL() string {
	if a.cfg.Sandbox {
		return qqSandboxURL
	}
	return qqBaseURL
}

func (a *adapter) getGatewayURL(ctx context.Context, token string) (string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.apiBaseURL()+"/gateway", nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "QQBot "+token)
	resp, err := qqHTTPClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("qq gateway api error %d: %s", resp.StatusCode, string(respBody))
	}
	var result struct {
		URL string `json:"url"`
	}
	if err := json.Unmarshal(respBody, &result); err != nil {
		return "", err
	}
	return validateGatewayURL(result.URL)
}

func validateGatewayURL(raw string) (string, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", fmt.Errorf("empty qq gateway url")
	}
	u, err := url.Parse(raw)
	if err != nil {
		return "", err
	}
	if u.Scheme != "wss" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || !allowedGatewayHost(u.Hostname()) {
		return "", fmt.Errorf("unexpected qq gateway url: %s", raw)
	}
	return u.String(), nil
}

func allowedGatewayHost(hostname string) bool {
	hostname = strings.ToLower(strings.TrimSpace(hostname))
	for _, allowed := range allowedGatewayHosts {
		if hostname == allowed || strings.HasSuffix(hostname, "."+allowed) {
			return true
		}
	}
	return false
}

func sanitizeHeartbeatInterval(interval time.Duration) time.Duration {
	if interval <= 0 {
		return qqMinHeartbeat
	}
	if interval < qqMinHeartbeat {
		return qqMinHeartbeat
	}
	if interval > qqMaxHeartbeat {
		return qqMaxHeartbeat
	}
	return interval
}

func (ws *wsClient) send(op int, d json.RawMessage) error {
	payload := gatewayPayload{Op: op, D: d}
	data, _ := json.Marshal(payload)
	_, err := ws.conn.Write(data)
	return err
}

func (a *adapter) handleDispatch(msg gatewayPayload) {
	var evt dispatchEvent
	if err := json.Unmarshal(msg.D, &evt); err != nil {
		a.logger.Error("parse dispatch", "err", err)
		return
	}

	ib := bot.InboundMessage{
		Platform:  bot.PlatformQQ,
		UserID:    qqAuthorID(evt),
		UserName:  evt.Author.Username,
		Text:      evt.Content,
		MessageID: evt.ID,
	}

	switch msg.T {
	case "C2C_MESSAGE_CREATE":
		ib.ChatType = bot.ChatDM
		ib.ChatID = ib.UserID
	case "GROUP_AT_MESSAGE_CREATE":
		ib.ChatType = bot.ChatGroup
		ib.ChatID = evt.GroupOpenID
	case "AT_MESSAGE_CREATE":
		ib.ChatType = bot.ChatGuild
		ib.ChatID = evt.ChannelID
	case "DIRECT_MESSAGE_CREATE":
		ib.ChatType = bot.ChatDirect
		ib.ChatID = evt.GuildID
	case "MESSAGE_CREATE":
		ib.ChatType = bot.ChatDM
		ib.ChatID = evt.ChannelID
	default:
		if strings.TrimSpace(msg.T) != "" {
			a.logger.Info("qq dispatch ignored", "event", msg.T)
		}
		return // 忽略其他事件
	}
	a.logger.Info("qq dispatch received", "event", msg.T, "chat_type", ib.ChatType)

	select {
	case a.msgCh <- ib:
	default:
		a.logger.Warn("message channel full, dropping message")
	}
}

func qqAuthorID(evt dispatchEvent) string {
	for _, value := range []string{evt.Author.UserOpenID, evt.Author.MemberOpenID, evt.Author.UnionOpenID, evt.Author.ID} {
		if value = strings.TrimSpace(value); value != "" {
			return value
		}
	}
	return ""
}

// sendMessage 使用 QQ REST API 发送消息。
func (a *adapter) sendMessage(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
	text := normalizeQQMarkdownReply(msg.Text)
	chunks := splitQQMessage(text, qqMaxChunkBytes)
	if len(chunks) == 0 {
		chunks = []string{""}
	}
	originalChunkCount := len(chunks)
	var truncated bool
	chunks, truncated = capQQPassiveReplyChunks(msg, chunks)
	if truncated {
		a.logger.Warn("qq passive reply truncated", "chat_type", msg.ChatType, "chunks", originalChunkCount, "limit", len(chunks))
	}
	var delivered bot.SendResult
	for _, chunk := range chunks {
		seq := a.nextMessageSeq(msg.ReplyToMsgID)
		var result bot.SendResult
		var err error
		if msg.Keyboard == nil && a.markdownDeliveryDisabled() {
			result, err = a.sendPlainMessageChunk(ctx, msg, chunk, seq)
		} else {
			result, err = a.sendMessageChunk(ctx, msg, chunk, seq)
		}
		if err != nil && msg.Keyboard == nil {
			a.disableMarkdownDelivery()
			a.logger.Warn("qq markdown delivery failed, retrying plain text", "chat_type", msg.ChatType, "err", err)
			result, err = a.sendPlainMessageChunk(ctx, msg, chunk, a.nextMessageSeq(msg.ReplyToMsgID))
		}
		if err != nil {
			a.logger.Error("qq message send failed", "chat_type", msg.ChatType, "err", err)
			return delivered, err
		}
		a.logger.Info("qq message sent", "chat_type", msg.ChatType, "message_id_set", strings.TrimSpace(result.MessageID) != "")
		delivered.Merge(result)
	}
	return delivered, nil
}

func (a *adapter) sendPlainMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
	return a.sendMessagePayload(ctx, msg, map[string]any{
		"content":  text,
		"msg_type": 0,
	}, seq)
}

func (a *adapter) sendMessageChunk(ctx context.Context, msg bot.OutboundMessage, text string, seq int) (bot.SendResult, error) {
	if msg.Keyboard != nil {
		payload := map[string]any{
			"content":  text,
			"msg_type": 2,
		}
		rows := make([]map[string]any, 0, len(msg.Keyboard.Rows))
		for _, row := range msg.Keyboard.Rows {
			buttons := make([]map[string]any, 0, len(row.Buttons))
			for _, btn := range row.Buttons {
				buttons = append(buttons, map[string]any{
					"id": strings.TrimSpace(btn.ID),
					"render_data": map[string]any{
						"label": btn.Label,
						"style": btn.Style,
					},
					"action": map[string]any{
						"type": 2,
						"data": btn.CallbackID,
					},
				})
			}
			rows = append(rows, map[string]any{"buttons": buttons})
		}
		payload["keyboard"] = map[string]any{
			"content": rows,
		}
		return a.sendMessagePayload(ctx, msg, payload, seq)
	}
	return a.sendMessagePayload(ctx, msg, map[string]any{
		"markdown": map[string]string{"content": text},
		"msg_type": 2,
	}, seq)
}

func (a *adapter) sendMessagePayload(ctx context.Context, msg bot.OutboundMessage, payload map[string]any, seq int) (bot.SendResult, error) {
	token, err := a.getAccessToken(ctx)
	if err != nil {
		return bot.SendResult{}, err
	}

	if msg.ReplyToMsgID != "" {
		payload["msg_id"] = msg.ReplyToMsgID
	}
	if seq > 0 {
		payload["msg_seq"] = seq
	}

	url := a.qqSendURL(msg)

	body, _ := json.Marshal(payload)
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(body))
	if err != nil {
		return bot.SendResult{}, err
	}
	req.Header.Set("Authorization", "QQBot "+token)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-Union-Appid", a.appID())

	resp, err := qqHTTPClient.Do(req)
	if err != nil {
		return bot.SendResult{}, err
	}
	defer resp.Body.Close()

	var result struct {
		ID        string `json:"id"`
		Timestamp string `json:"timestamp"`
	}
	respBody, _ := io.ReadAll(resp.Body)
	if resp.StatusCode >= 400 {
		return bot.SendResult{}, fmt.Errorf("qq api error %d: %s", resp.StatusCode, string(respBody))
	}
	if err := json.Unmarshal(respBody, &result); err != nil {
		return bot.SendResult{}, fmt.Errorf("decode send response: %w", err)
	}

	return bot.SendResult{MessageID: result.ID}, nil
}

func (a *adapter) qqSendURL(msg bot.OutboundMessage) string {
	base := a.apiBaseURL()
	switch msg.ChatType {
	case bot.ChatGroup:
		return fmt.Sprintf("%s/v2/groups/%s/messages", base, url.PathEscape(msg.ChatID))
	case bot.ChatGuild, bot.ChatThread:
		return fmt.Sprintf("%s/v2/channels/%s/messages", base, url.PathEscape(msg.ChatID))
	case bot.ChatDirect:
		return fmt.Sprintf("%s/v2/dms/%s/messages", base, url.PathEscape(msg.ChatID))
	default:
		return fmt.Sprintf("%s/v2/users/%s/messages", base, url.PathEscape(msg.ChatID))
	}
}

func qqSendURL(msg bot.OutboundMessage) string {
	return (&adapter{}).qqSendURL(msg)
}

func (a *adapter) nextMessageSeq(replyTo string) int {
	if strings.TrimSpace(replyTo) == "" {
		return 0
	}
	a.sendMu.Lock()
	defer a.sendMu.Unlock()
	if a.nextOutboundMsgSeq <= 0 {
		a.nextOutboundMsgSeq = 1
	}
	seq := a.nextOutboundMsgSeq
	a.nextOutboundMsgSeq++
	return seq
}

func (a *adapter) markdownDeliveryDisabled() bool {
	a.sendMu.Lock()
	defer a.sendMu.Unlock()
	return a.markdownDisabled
}

func (a *adapter) disableMarkdownDelivery() {
	a.sendMu.Lock()
	defer a.sendMu.Unlock()
	a.markdownDisabled = true
}

func normalizeQQMarkdownReply(text string) string {
	match := qqMarkdownWrapperRe.FindStringSubmatch(strings.TrimSpace(text))
	if len(match) != 2 {
		return text
	}
	return match[1]
}

func splitQQMessage(text string, maxBytes int) []string {
	if maxBytes <= 0 {
		maxBytes = qqMaxChunkBytes
	}
	var chunks []string
	remaining := text
	for remaining != "" {
		if len([]byte(remaining)) <= maxBytes {
			chunks = append(chunks, remaining)
			break
		}
		candidate := fitUTF8Slice(remaining, maxBytes)
		splitAt := pickNaturalSplit(candidate)
		chunks = append(chunks, candidate[:splitAt])
		remaining = strings.TrimLeft(remaining[splitAt:], " \t\r\n")
	}
	return chunks
}

func capQQPassiveReplyChunks(msg bot.OutboundMessage, chunks []string) ([]string, bool) {
	if !qqUsesPassiveReplyLimit(msg) || len(chunks) <= qqMaxPassiveReplyChunks {
		return chunks, false
	}
	capped := make([]string, 0, qqMaxPassiveReplyChunks)
	capped = append(capped, chunks[:qqMaxPassiveReplyChunks-1]...)
	capped = append(capped, fitQQChunkWithSuffix(chunks[qqMaxPassiveReplyChunks-1], qqPassiveReplyTruncationNotice, qqMaxChunkBytes))
	return capped, true
}

func qqUsesPassiveReplyLimit(msg bot.OutboundMessage) bool {
	if strings.TrimSpace(msg.ReplyToMsgID) == "" {
		return false
	}
	return msg.ChatType == bot.ChatDM || msg.ChatType == bot.ChatGroup
}

func fitQQChunkWithSuffix(text, suffix string, maxBytes int) string {
	if maxBytes <= 0 {
		maxBytes = qqMaxChunkBytes
	}
	suffixBytes := len([]byte(suffix))
	if suffixBytes >= maxBytes {
		return fitUTF8Slice(suffix, maxBytes)
	}
	prefix := strings.TrimRight(fitUTF8Slice(text, maxBytes-suffixBytes), " \t\r\n")
	if prefix == "" {
		return strings.TrimLeft(fitUTF8Slice(suffix, maxBytes), " \t\r\n")
	}
	return prefix + suffix
}

func fitUTF8Slice(text string, maxBytes int) string {
	return textutil.FitGraphemeBytes(text, maxBytes)
}

func pickNaturalSplit(candidate string) int {
	if candidate == "" {
		return 0
	}
	minSplit := len(candidate) * 6 / 10
	for _, sep := range []string{"\n\n", "\n", " "} {
		if at := strings.LastIndex(candidate, sep); at >= minSplit {
			return at + len(sep)
		}
	}
	return len(candidate)
}
