package weixin

import (
	"bytes"
	"context"
	"crypto/aes"
	"crypto/md5"
	"crypto/rand"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"io"
	"mime"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"github.com/h2non/filetype"

	"github.com/dapicom-ai/omnipus/pkg/bus"
	basechannels "github.com/dapicom-ai/omnipus/pkg/channels"
	"github.com/dapicom-ai/omnipus/pkg/logger"
	"github.com/dapicom-ai/omnipus/pkg/media"
)

const (
	weixinMediaMaxBytes         = 100 << 20
	weixinTypingKeepAlive       = 5 * time.Second
	weixinUploadRetryMax        = 3
	weixinDownloadRetryMax      = 2
	weixinDownloadRetryDelay    = 300 * time.Millisecond
	weixinVoiceTranscodeTimeout = 15 * time.Second
)

type uploadedFileInfo struct {
	downloadParam string
	aesKeyHex     string
	fileSize      int64
	cipherSize    int64
	filename      string
}

func pkcs7Pad(src []byte, blockSize int) []byte {
	padding := blockSize - len(src)%blockSize
	if padding == 0 {
		padding = blockSize
	}
	out := make([]byte, len(src)+padding)
	copy(out, src)
	for i := len(src); i < len(out); i++ {
		out[i] = byte(padding)
	}
	return out
}

func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
	if len(src) == 0 || len(src)%blockSize != 0 {
		return nil, fmt.Errorf("invalid padded data size %d", len(src))
	}
	padding := int(src[len(src)-1])
	if padding <= 0 || padding > blockSize || padding > len(src) {
		return nil, fmt.Errorf("invalid padding size %d", padding)
	}
	for i := len(src) - padding; i < len(src); i++ {
		if src[i] != byte(padding) {
			return nil, fmt.Errorf("invalid padding content")
		}
	}
	return src[:len(src)-padding], nil
}

func encryptAESECB(plaintext, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	padded := pkcs7Pad(plaintext, block.BlockSize())
	out := make([]byte, len(padded))
	for i := 0; i < len(padded); i += block.BlockSize() {
		block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()])
	}
	return out, nil
}

func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	if len(ciphertext)%block.BlockSize() != 0 {
		return nil, fmt.Errorf("invalid ciphertext size %d", len(ciphertext))
	}
	out := make([]byte, len(ciphertext))
	for i := 0; i < len(ciphertext); i += block.BlockSize() {
		block.Decrypt(out[i:i+block.BlockSize()], ciphertext[i:i+block.BlockSize()])
	}
	return pkcs7Unpad(out, block.BlockSize())
}

func parseWeixinMediaAESKey(aesKeyBase64 string) ([]byte, error) {
	decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
	if err != nil {
		return nil, err
	}
	if len(decoded) == 16 {
		return decoded, nil
	}
	if len(decoded) == 32 {
		if raw, err := hex.DecodeString(string(decoded)); err == nil && len(raw) == 16 {
			return raw, nil
		}
	}
	return nil, fmt.Errorf("unsupported aes_key length %d", len(decoded))
}

func imageAESKey(img *ImageItem) ([]byte, bool, error) {
	if img == nil {
		return nil, false, nil
	}
	if img.Aeskey != "" {
		raw, err := hex.DecodeString(img.Aeskey)
		if err != nil {
			return nil, false, err
		}
		return raw, true, nil
	}
	if img.Media != nil && img.Media.AesKey != "" {
		raw, err := parseWeixinMediaAESKey(img.Media.AesKey)
		if err != nil {
			return nil, false, err
		}
		return raw, true, nil
	}
	return nil, false, nil
}

func genericMediaAESKey(mediaRef *CDNMedia) ([]byte, error) {
	if mediaRef == nil || mediaRef.AesKey == "" {
		return nil, fmt.Errorf("missing aes_key")
	}
	return parseWeixinMediaAESKey(mediaRef.AesKey)
}

func aesEcbPaddedSize(size int64) int64 {
	return (size/16 + 1) * 16
}

func randomHex(n int) (string, error) {
	buf := make([]byte, n)
	if _, err := rand.Read(buf); err != nil {
		return "", err
	}
	return hex.EncodeToString(buf), nil
}

func buildCDNDownloadURL(base, encryptedQueryParam string) string {
	return strings.TrimRight(base, "/") +
		"/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
}

func shouldRetryCDNDownload(statusCode int) bool {
	// statusCode=0 represents transport/build errors from the HTTP client.
	return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests
}

func buildCDNUploadURL(base, uploadParam, filekey string) string {
	return strings.TrimRight(base, "/") +
		"/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
		"&filekey=" + url.QueryEscape(filekey)
}

func uniqCDNURLs(urls []string) []string {
	seen := make(map[string]struct{}, len(urls))
	out := make([]string, 0, len(urls))
	for _, raw := range urls {
		u := strings.TrimSpace(raw)
		if u == "" {
			continue
		}
		if _, ok := seen[u]; ok {
			continue
		}
		seen[u] = struct{}{}
		out = append(out, u)
	}
	return out
}

func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
	if err != nil {
		return nil, 0, err
	}
	resp, err := c.api.HttpClient.Do(req)
	if err != nil {
		return nil, 0, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return nil, resp.StatusCode, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
	if err != nil {
		return nil, resp.StatusCode, err
	}
	if len(data) > weixinMediaMaxBytes {
		return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data))
	}
	return data, resp.StatusCode, nil
}

func (c *WeixinChannel) downloadCDNBuffer(
	ctx context.Context,
	encryptedQueryParam,
	fullURL string,
) ([]byte, error) {
	candidates := uniqCDNURLs([]string{
		strings.TrimSpace(fullURL),
		func() string {
			if strings.TrimSpace(encryptedQueryParam) == "" {
				return ""
			}
			return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam)
		}(),
	})
	if len(candidates) == 0 {
		return nil, fmt.Errorf("missing CDN download URL")
	}

	var lastErr error
	for _, downloadURL := range candidates {
		for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ {
			data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL)
			if err == nil {
				return data, nil
			}
			lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL)
			if !shouldRetryCDNDownload(statusCode) {
				break
			}
			if attempt < weixinDownloadRetryMax {
				select {
				case <-ctx.Done():
					return nil, ctx.Err()
				case <-time.After(weixinDownloadRetryDelay):
				}
			}
		}
	}
	return nil, lastErr
}

func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
	ctx context.Context,
	encryptedQueryParam string,
	fullURL string,
	key []byte,
) ([]byte, error) {
	data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL)
	if err != nil {
		return nil, err
	}
	if len(key) == 0 {
		return data, nil
	}
	return decryptAESECB(data, key)
}

func (c *WeixinChannel) downloadImageBuffer(
	ctx context.Context,
	img *ImageItem,
	key []byte,
) ([]byte, error) {
	if img == nil {
		return nil, fmt.Errorf("image item is nil")
	}
	if img.Media != nil {
		data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key)
		if err == nil {
			return data, nil
		}
		if img.ThumbMedia == nil {
			return nil, fmt.Errorf("image download failed: %w", err)
		}
	}
	if img.ThumbMedia != nil {
		data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key)
		if err == nil {
			return data, nil
		}
		return nil, fmt.Errorf("image download failed: %w", err)
	}
	return nil, fmt.Errorf("image media is nil")
}

func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) {
	contentType := strings.TrimSpace(fallbackContentType)
	ext := filepath.Ext(fallbackName)
	if kind, err := filetype.Match(data); err == nil && kind != filetype.Unknown {
		contentType = kind.MIME.Value
		if kind.Extension != "" {
			ext = "." + kind.Extension
		}
	}
	if contentType == "" && ext != "" {
		contentType = mime.TypeByExtension(strings.ToLower(ext))
	}
	if contentType == "" {
		contentType = http.DetectContentType(data)
	}
	if ext == "" && contentType != "" {
		if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
			ext = exts[0]
		}
	}

	filename := sanitizeFilename(fallbackName)
	if filename == "" {
		filename = "media"
	}
	if filepath.Ext(filename) == "" && ext != "" {
		filename += ext
	}
	return filename, contentType
}

func sanitizeFilename(name string) string {
	name = filepath.Base(strings.TrimSpace(name))
	if name == "." || name == "/" || name == "" {
		return ""
	}
	return name
}

func writeManagedTempFile(prefix, filename string, data []byte) (string, error) {
	if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
		return "", err
	}
	pattern := prefix + "-*"
	if ext := filepath.Ext(filename); ext != "" {
		pattern += ext
	}
	f, err := os.CreateTemp(media.TempDir(), pattern)
	if err != nil {
		return "", err
	}
	defer f.Close()
	if _, err := f.Write(data); err != nil {
		os.Remove(f.Name())
		return "", err
	}
	return f.Name(), nil
}

func (c *WeixinChannel) storeInboundBytes(
	chatID,
	messageID,
	filename,
	contentType string,
	data []byte,
) (string, error) {
	store := c.GetMediaStore()
	if store == nil {
		return "", fmt.Errorf("no media store available")
	}
	filename, contentType = detectMediaMetadata(data, filename, contentType)
	tmpPath, err := writeManagedTempFile("weixin-inbound", filename, data)
	if err != nil {
		return "", err
	}
	ref, err := store.Store(tmpPath, media.MediaMeta{
		Filename:      filename,
		ContentType:   contentType,
		Source:        "weixin",
		CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
	}, basechannels.BuildMediaScope("weixin", chatID, messageID))
	if err != nil {
		os.Remove(tmpPath)
		return "", err
	}
	return ref, nil
}

func isDownloadableMediaItem(item *MessageItem) bool {
	if item == nil {
		return false
	}

	switch item.Type {
	case MessageItemTypeImage:
		return item.ImageItem != nil && item.ImageItem.Media != nil &&
			(item.ImageItem.Media.EncryptQueryParam != "" || item.ImageItem.Media.FullURL != "")
	case MessageItemTypeVideo:
		return item.VideoItem != nil && item.VideoItem.Media != nil &&
			(item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "")
	case MessageItemTypeFile:
		return item.FileItem != nil && item.FileItem.Media != nil &&
			(item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "")
	case MessageItemTypeVoice:
		return item.VoiceItem != nil &&
			item.VoiceItem.Media != nil &&
			(item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") &&
			strings.TrimSpace(item.VoiceItem.Text) == ""
	default:
		return false
	}
}

func selectInboundMediaItem(msg WeixinMessage) *MessageItem {
	priorities := []int{
		MessageItemTypeImage,
		MessageItemTypeVideo,
		MessageItemTypeFile,
		MessageItemTypeVoice,
	}

	for _, want := range priorities {
		for i := range msg.ItemList {
			item := &msg.ItemList[i]
			if item.Type == want && isDownloadableMediaItem(item) {
				return item
			}
		}
	}

	for i := range msg.ItemList {
		item := &msg.ItemList[i]
		if item.Type != MessageItemTypeText || item.RefMsg == nil || item.RefMsg.MessageItem == nil {
			continue
		}
		if isDownloadableMediaItem(item.RefMsg.MessageItem) {
			return item.RefMsg.MessageItem
		}
	}

	return nil
}

func tryTranscodeSilkToWAV(ctx context.Context, silk []byte) ([]byte, error) {
	decoders := []struct {
		name string
		args func(inputPath, outputPath string) []string
	}{
		{
			name: "silk_v3_decoder",
			args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
		},
		{
			name: "silk_decoder",
			args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
		},
		{
			name: "ffmpeg",
			args: func(inputPath, outputPath string) []string {
				return []string{"-y", "-i", inputPath, outputPath}
			},
		},
	}

	for _, decoder := range decoders {
		bin, err := exec.LookPath(decoder.name)
		if err != nil {
			continue
		}

		tmpIn, err := writeManagedTempFile("weixin-voice", "voice.silk", silk)
		if err != nil {
			return nil, err
		}
		tmpOut := filepath.Join(media.TempDir(), "weixin-voice-"+uuid.New().String()+".wav")
		wav, ok := func() ([]byte, bool) {
			defer os.Remove(tmpIn)
			defer os.Remove(tmpOut)

			runCtx, cancel := context.WithTimeout(ctx, weixinVoiceTranscodeTimeout)
			cmd := exec.CommandContext(runCtx, bin, decoder.args(tmpIn, tmpOut)...)
			out, runErr := cmd.CombinedOutput()
			cancel()
			if runErr != nil {
				logger.DebugCF("weixin", "SILK transcode command failed", map[string]any{
					"decoder": decoder.name,
					"error":   runErr.Error(),
					"output":  strings.TrimSpace(string(out)),
				})
				return nil, false
			}

			wav, readErr := os.ReadFile(tmpOut)
			if readErr != nil {
				logger.DebugCF("weixin", "Failed to read transcoded WAV", map[string]any{
					"decoder": decoder.name,
					"error":   readErr.Error(),
				})
				return nil, false
			}
			return wav, len(wav) > 0
		}()
		if ok {
			return wav, nil
		}
	}

	return nil, fmt.Errorf("no SILK decoder available")
}

func (c *WeixinChannel) downloadMediaFromItem(
	ctx context.Context,
	chatID,
	messageID string,
	item *MessageItem,
) (string, error) {
	if item == nil {
		return "", nil
	}

	switch item.Type {
	case MessageItemTypeImage:
		if item.ImageItem == nil {
			return "", fmt.Errorf("image media is nil")
		}
		key, ok, err := imageAESKey(item.ImageItem)
		if err != nil {
			return "", err
		}
		decryptKey := func() []byte {
			if ok {
				return key
			}
			return nil
		}()
		data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey)
		if err != nil {
			return "", err
		}
		return c.storeInboundBytes(chatID, messageID, "image", "", data)

	case MessageItemTypeVoice:
		key, err := genericMediaAESKey(item.VoiceItem.Media)
		if err != nil {
			return "", err
		}
		silk, err := c.downloadAndDecryptCDNBuffer(
			ctx,
			item.VoiceItem.Media.EncryptQueryParam,
			item.VoiceItem.Media.FullURL,
			key,
		)
		if err != nil {
			return "", err
		}
		if wav, err := tryTranscodeSilkToWAV(ctx, silk); err == nil && len(wav) > 0 {
			return c.storeInboundBytes(chatID, messageID, "voice.wav", "audio/wav", wav)
		}
		return c.storeInboundBytes(chatID, messageID, "voice.silk", "audio/silk", silk)

	case MessageItemTypeFile:
		key, err := genericMediaAESKey(item.FileItem.Media)
		if err != nil {
			return "", err
		}
		data, err := c.downloadAndDecryptCDNBuffer(
			ctx,
			item.FileItem.Media.EncryptQueryParam,
			item.FileItem.Media.FullURL,
			key,
		)
		if err != nil {
			return "", err
		}
		filename := item.FileItem.FileName
		if filename == "" {
			filename = "file.bin"
		}
		contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
		return c.storeInboundBytes(chatID, messageID, filename, contentType, data)

	case MessageItemTypeVideo:
		key, err := genericMediaAESKey(item.VideoItem.Media)
		if err != nil {
			return "", err
		}
		data, err := c.downloadAndDecryptCDNBuffer(
			ctx,
			item.VideoItem.Media.EncryptQueryParam,
			item.VideoItem.Media.FullURL,
			key,
		)
		if err != nil {
			return "", err
		}
		return c.storeInboundBytes(chatID, messageID, "video.mp4", "video/mp4", data)
	}

	return "", nil
}

func outboundMediaKind(partType, filename, contentType string) int {
	switch strings.ToLower(strings.TrimSpace(partType)) {
	case "image":
		return UploadMediaTypeImage
	case "video":
		return UploadMediaTypeVideo
	}

	ct := strings.ToLower(contentType)
	switch {
	case strings.HasPrefix(ct, "image/"):
		return UploadMediaTypeImage
	case strings.HasPrefix(ct, "video/"):
		return UploadMediaTypeVideo
	default:
		return UploadMediaTypeFile
	}
}

func detectLocalContentType(localPath, hintContentType string) string {
	if strings.TrimSpace(hintContentType) != "" {
		return hintContentType
	}
	if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown {
		return kind.MIME.Value
	}
	if ext := filepath.Ext(localPath); ext != "" {
		if ct := mime.TypeByExtension(strings.ToLower(ext)); ct != "" {
			return ct
		}
	}
	return "application/octet-stream"
}

func downloadFilenameFromURL(rawURL, fallback string) string {
	if fallback = sanitizeFilename(fallback); fallback != "" {
		return fallback
	}
	parsed, err := url.Parse(rawURL)
	if err == nil {
		if base := sanitizeFilename(path.Base(parsed.Path)); base != "" {
			return base
		}
	}
	return "remote-media"
}

func (c *WeixinChannel) downloadRemoteMediaToTemp(
	ctx context.Context,
	rawURL,
	fallbackName string,
) (string, string, string, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
	if err != nil {
		return "", "", "", err
	}
	resp, err := c.api.HttpClient.Do(req)
	if err != nil {
		return "", "", "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return "", "", "", fmt.Errorf("remote media HTTP %d: %s", resp.StatusCode, string(body))
	}

	data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
	if err != nil {
		return "", "", "", err
	}
	if len(data) > weixinMediaMaxBytes {
		return "", "", "", fmt.Errorf("remote media too large: %d bytes", len(data))
	}

	filename, contentType := detectMediaMetadata(
		data,
		downloadFilenameFromURL(rawURL, fallbackName),
		resp.Header.Get("Content-Type"),
	)
	tmpPath, err := writeManagedTempFile("weixin-remote", filename, data)
	if err != nil {
		return "", "", "", err
	}
	return tmpPath, filename, contentType, nil
}

func (c *WeixinChannel) resolveOutboundPart(
	ctx context.Context,
	part bus.MediaPart,
) (string, string, string, func(), error) {
	cleanup := func() {}
	filename := sanitizeFilename(part.Filename)
	contentType := strings.TrimSpace(part.ContentType)

	switch {
	case strings.HasPrefix(part.Ref, "http://") || strings.HasPrefix(part.Ref, "https://"):
		localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, part.Ref, filename)
		if err != nil {
			return "", "", "", cleanup, err
		}
		return localPath, name, ct, func() { os.Remove(localPath) }, nil

	case strings.HasPrefix(part.Ref, "media://"):
		store := c.GetMediaStore()
		if store == nil {
			return "", "", "", cleanup, fmt.Errorf("no media store available")
		}
		localPath, meta, err := store.ResolveWithMeta(part.Ref)
		if err != nil {
			return "", "", "", cleanup, err
		}
		if filename == "" {
			filename = sanitizeFilename(meta.Filename)
		}
		if contentType == "" {
			contentType = meta.ContentType
		}
		if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") {
			tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename)
			if err != nil {
				return "", "", "", cleanup, err
			}
			return tmpPath, name, ct, func() { os.Remove(tmpPath) }, nil
		}
		if filename == "" {
			filename = sanitizeFilename(filepath.Base(localPath))
		}
		if contentType == "" {
			contentType = detectLocalContentType(localPath, "")
		}
		return localPath, filename, contentType, cleanup, nil

	case strings.HasPrefix(part.Ref, "file://"):
		u, err := url.Parse(part.Ref)
		if err != nil {
			return "", "", "", cleanup, err
		}
		localPath := u.Path
		if filename == "" {
			filename = sanitizeFilename(filepath.Base(localPath))
		}
		if contentType == "" {
			contentType = detectLocalContentType(localPath, "")
		}
		return localPath, filename, contentType, cleanup, nil

	default:
		localPath := part.Ref
		if filename == "" {
			filename = sanitizeFilename(filepath.Base(localPath))
		}
		if contentType == "" {
			contentType = detectLocalContentType(localPath, "")
		}
		return localPath, filename, contentType, cleanup, nil
	}
}

func (c *WeixinChannel) uploadLocalFile(
	ctx context.Context,
	localPath,
	filename,
	toUserID string,
	mediaType int,
) (*uploadedFileInfo, error) {
	data, err := os.ReadFile(localPath)
	if err != nil {
		return nil, err
	}
	if len(data) > weixinMediaMaxBytes {
		return nil, fmt.Errorf("media too large: %d bytes", len(data))
	}

	filekey, err := randomHex(16)
	if err != nil {
		return nil, err
	}
	aesKey := make([]byte, 16)
	if _, readErr := rand.Read(aesKey); readErr != nil {
		return nil, readErr
	}
	aesKeyHex := hex.EncodeToString(aesKey)
	rawMD5 := md5.Sum(data)

	resp, err := c.api.GetUploadUrl(ctx, GetUploadUrlReq{
		Filekey:     filekey,
		MediaType:   mediaType,
		ToUserID:    toUserID,
		Rawsize:     int64(len(data)),
		RawfileMD5:  hex.EncodeToString(rawMD5[:]),
		Filesize:    aesEcbPaddedSize(int64(len(data))),
		NoNeedThumb: true,
		Aeskey:      aesKeyHex,
	})
	if err != nil {
		return nil, err
	}
	if resp == nil {
		return nil, fmt.Errorf("getuploadurl returned nil response")
	}
	if resp.Ret != 0 || resp.Errcode != 0 {
		if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
			c.pauseSession("getuploadurl", resp.Ret, resp.Errcode, resp.Errmsg)
		}
		return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
	}
	uploadParam := strings.TrimSpace(resp.UploadParam)
	uploadFullURL := strings.TrimSpace(resp.UploadFullURL)
	if uploadParam == "" && uploadFullURL == "" {
		return nil, fmt.Errorf("getuploadurl returned no upload URL")
	}

	downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, filekey, aesKey)
	if err != nil {
		return nil, err
	}

	return &uploadedFileInfo{
		downloadParam: downloadParam,
		aesKeyHex:     aesKeyHex,
		fileSize:      int64(len(data)),
		cipherSize:    aesEcbPaddedSize(int64(len(data))),
		filename:      filename,
	}, nil
}

func (c *WeixinChannel) uploadBufferToCDN(
	ctx context.Context,
	plaintext []byte,
	uploadParam,
	uploadFullURL,
	filekey string,
	aesKey []byte,
) (string, error) {
	ciphertext, err := encryptAESECB(plaintext, aesKey)
	if err != nil {
		return "", err
	}

	uploadURL := strings.TrimSpace(uploadFullURL)
	if uploadURL == "" {
		if strings.TrimSpace(uploadParam) == "" {
			return "", fmt.Errorf("missing CDN upload URL")
		}
		uploadURL = buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey)
	}
	var lastErr error

	for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ {
		req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(ciphertext))
		if reqErr != nil {
			return "", reqErr
		}
		req.Header.Set("Content-Type", "application/octet-stream")

		resp, doErr := c.api.HttpClient.Do(req)
		if doErr != nil {
			lastErr = doErr
		} else {
			func() {
				defer resp.Body.Close()
				if resp.StatusCode >= 400 && resp.StatusCode < 500 {
					body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
					lastErr = fmt.Errorf(
						"cdn upload client error %d: %s",
						resp.StatusCode,
						strings.TrimSpace(string(body)),
					)
					return
				}
				if resp.StatusCode != http.StatusOK {
					body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
					lastErr = fmt.Errorf(
						"cdn upload server error %d: %s",
						resp.StatusCode,
						strings.TrimSpace(string(body)),
					)
					return
				}
				if encrypted := strings.TrimSpace(resp.Header.Get("X-Encrypted-Param")); encrypted != "" {
					lastErr = nil
					uploadParam = encrypted
					return
				}
				lastErr = fmt.Errorf("cdn upload missing x-encrypted-param header")
			}()
		}

		if lastErr == nil {
			return uploadParam, nil
		}
		if strings.Contains(lastErr.Error(), "client error") || attempt == weixinUploadRetryMax {
			break
		}
	}

	return "", lastErr
}

func (c *WeixinChannel) sendMessageItem(
	ctx context.Context,
	toUserID,
	contextToken string,
	item MessageItem,
) error {
	resp, err := c.api.SendMessage(ctx, SendMessageReq{
		Msg: WeixinMessage{
			ToUserID:     toUserID,
			ClientID:     "omnipus-" + uuid.New().String(),
			MessageType:  MessageTypeBot,
			MessageState: MessageStateFinish,
			ItemList:     []MessageItem{item},
			ContextToken: contextToken,
		},
	})
	if err != nil {
		return err
	}
	if resp == nil {
		return fmt.Errorf("sendmessage returned nil response")
	}
	if resp.Ret != 0 || resp.Errcode != 0 {
		if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
			c.pauseSession("sendmessage", resp.Ret, resp.Errcode, resp.Errmsg)
		}
		return fmt.Errorf("sendmessage failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
	}
	return nil
}

func (c *WeixinChannel) sendTextMessage(
	ctx context.Context,
	toUserID,
	contextToken,
	text string,
) error {
	if strings.TrimSpace(text) == "" {
		return nil
	}
	return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
		Type: MessageItemTypeText,
		TextItem: &TextItem{
			Text: text,
		},
	})
}

func encodeWeixinOutboundAESKey(aesKeyHex string) string {
	return base64.StdEncoding.EncodeToString([]byte(aesKeyHex))
}

func (c *WeixinChannel) sendUploadedMedia(
	ctx context.Context,
	toUserID,
	contextToken,
	caption string,
	mediaType int,
	uploaded *uploadedFileInfo,
) error {
	if err := c.sendTextMessage(ctx, toUserID, contextToken, caption); err != nil {
		return err
	}

	mediaRef := &CDNMedia{
		EncryptQueryParam: uploaded.downloadParam,
		AesKey:            encodeWeixinOutboundAESKey(uploaded.aesKeyHex),
		EncryptType:       1,
	}

	switch mediaType {
	case UploadMediaTypeImage:
		return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
			Type: MessageItemTypeImage,
			ImageItem: &ImageItem{
				Media:   mediaRef,
				MidSize: uploaded.cipherSize,
			},
		})

	case UploadMediaTypeVideo:
		return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
			Type: MessageItemTypeVideo,
			VideoItem: &VideoItem{
				Media:     mediaRef,
				VideoSize: uploaded.cipherSize,
			},
		})

	default:
		return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
			Type: MessageItemTypeFile,
			FileItem: &FileItem{
				Media:    mediaRef,
				FileName: uploaded.filename,
				Len:      fmt.Sprintf("%d", uploaded.fileSize),
			},
		})
	}
}

func (c *WeixinChannel) sendTypingStatus(
	ctx context.Context,
	chatID,
	typingTicket string,
	status int,
) error {
	resp, err := c.api.SendTyping(ctx, SendTypingReq{
		IlinkUserID:  chatID,
		TypingTicket: typingTicket,
		Status:       status,
	})
	if err != nil {
		return err
	}
	if resp == nil {
		return fmt.Errorf("sendtyping returned nil response")
	}
	if resp.Ret != 0 || resp.Errcode != 0 {
		if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
			c.pauseSession("sendtyping", resp.Ret, resp.Errcode, resp.Errmsg)
		}
		return fmt.Errorf("sendtyping failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
	}
	return nil
}

// StartTyping implements channels.TypingCapable.
func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
	if strings.TrimSpace(chatID) == "" {
		return func() {}, nil
	}
	if c.remainingPause() > 0 {
		return func() {}, nil
	}

	ticket, err := c.getTypingTicket(ctx, chatID)
	if err != nil {
		if ticket == "" {
			return func() {}, err
		}
		logger.DebugCF("weixin", "GetConfig refresh failed; using cached typing ticket", map[string]any{
			"chat_id": chatID,
			"error":   err.Error(),
		})
	}
	if ticket == "" {
		return func() {}, nil
	}

	typingCtx, cancel := context.WithCancel(ctx)
	var once sync.Once
	stop := func() {
		once.Do(func() {
			cancel()
			stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
			defer stopCancel()
			if err := c.sendTypingStatus(stopCtx, chatID, ticket, TypingStatusCancel); err != nil {
				logger.DebugCF("weixin", "Failed to cancel typing indicator", map[string]any{
					"chat_id": chatID,
					"error":   err.Error(),
				})
			}
		})
	}

	if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
		stop()
		return func() {}, err
	}

	ticker := time.NewTicker(weixinTypingKeepAlive)
	go func() {
		defer ticker.Stop()
		for {
			select {
			case <-typingCtx.Done():
				return
			case <-ticker.C:
				if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
					logger.DebugCF("weixin", "Failed to refresh typing indicator", map[string]any{
						"chat_id": chatID,
						"error":   err.Error(),
					})
				}
			}
		}
	}()

	return stop, nil
}

// SendMedia implements channels.MediaSender.
func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
	if !c.IsRunning() {
		return basechannels.ErrNotRunning
	}
	if err := c.ensureSessionActive(); err != nil {
		return err
	}

	contextToken := ""
	if v, ok := c.contextTokens.Load(msg.ChatID); ok {
		contextToken, _ = v.(string)
	}
	if contextToken == "" {
		return fmt.Errorf(
			"weixin send media: missing context token for chat %s: %w",
			msg.ChatID,
			basechannels.ErrSendFailed,
		)
	}

	for _, part := range msg.Parts {
		localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
		if err != nil {
			logger.ErrorCF("weixin", "Failed to resolve outbound media", map[string]any{
				"chat_id": msg.ChatID,
				"ref":     part.Ref,
				"error":   err.Error(),
			})
			return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
		}
		func() {
			if cleanup != nil {
				defer cleanup()
			}

			kind := outboundMediaKind(part.Type, filename, contentType)
			uploaded, uploadErr := c.uploadLocalFile(ctx, localPath, filename, msg.ChatID, kind)
			if uploadErr != nil {
				err = uploadErr
				return
			}
			err = c.sendUploadedMedia(ctx, msg.ChatID, contextToken, part.Caption, kind, uploaded)
		}()
		if err != nil {
			logger.ErrorCF("weixin", "Failed to send outbound media", map[string]any{
				"chat_id": msg.ChatID,
				"ref":     part.Ref,
				"error":   err.Error(),
			})
			if c.remainingPause() > 0 {
				return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
			}
			return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
		}
	}

	return nil
}
