package telegram

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"
	"unicode/utf8"

	"github.com/chenhg5/cc-connect/core"

	tgbot "github.com/go-telegram/bot"
	"github.com/go-telegram/bot/models"
)

type testLifecycleHandler struct {
	onReady       func(core.Platform)
	onUnavailable func(core.Platform, error)
}

func (h testLifecycleHandler) OnPlatformReady(p core.Platform) {
	if h.onReady != nil {
		h.onReady(p)
	}
}

func (h testLifecycleHandler) OnPlatformUnavailable(p core.Platform, err error) {
	if h.onUnavailable != nil {
		h.onUnavailable(p, err)
	}
}

type stubBackoffTimer struct {
	ch chan time.Time
}

func immediateTimer(time.Duration) backoffTimer {
	ch := make(chan time.Time)
	close(ch)
	return &stubBackoffTimer{ch: ch}
}

func (t *stubBackoffTimer) C() <-chan time.Time {
	return t.ch
}

func (t *stubBackoffTimer) Stop() bool {
	return true
}

type stubTypingTicker struct {
	ch chan time.Time
}

func newStubTypingTicker() *stubTypingTicker {
	return &stubTypingTicker{ch: make(chan time.Time, 8)}
}

func (t *stubTypingTicker) C() <-chan time.Time {
	return t.ch
}

func (t *stubTypingTicker) Stop() {}

type stubTelegramBot struct {
	mu                   sync.Mutex
	sendMessageCalls     int
	sendPhotoCalls       int
	sendDocumentCalls    int
	sendVoiceCalls       int
	sendAudioCalls       int
	sendChatActionCalls  int
	editMessageTextCalls int
	deleteMessageCalls   int
	answerCallbackCalls  int
	setMyCommandsCalls   int
	getFileCalls         int
	setReactionCalls     int

	sendErr    error
	getFileErr error
	file       *models.File
}

func newStubTelegramBot() *stubTelegramBot {
	return &stubTelegramBot{
		file: &models.File{FilePath: "files/test.dat"},
	}
}

func (b *stubTelegramBot) SendMessage(_ context.Context, _ *tgbot.SendMessageParams) (*models.Message, error) {
	b.mu.Lock()
	b.sendMessageCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) SendPhoto(_ context.Context, _ *tgbot.SendPhotoParams) (*models.Message, error) {
	b.mu.Lock()
	b.sendPhotoCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) SendDocument(_ context.Context, _ *tgbot.SendDocumentParams) (*models.Message, error) {
	b.mu.Lock()
	b.sendDocumentCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) SendVoice(_ context.Context, _ *tgbot.SendVoiceParams) (*models.Message, error) {
	b.mu.Lock()
	b.sendVoiceCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) SendAudio(_ context.Context, _ *tgbot.SendAudioParams) (*models.Message, error) {
	b.mu.Lock()
	b.sendAudioCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) SendChatAction(_ context.Context, _ *tgbot.SendChatActionParams) (bool, error) {
	b.mu.Lock()
	b.sendChatActionCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return false, b.sendErr
	}
	return true, nil
}

func (b *stubTelegramBot) EditMessageText(_ context.Context, _ *tgbot.EditMessageTextParams) (*models.Message, error) {
	b.mu.Lock()
	b.editMessageTextCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return nil, b.sendErr
	}
	return &models.Message{ID: 99}, nil
}

func (b *stubTelegramBot) DeleteMessage(_ context.Context, _ *tgbot.DeleteMessageParams) (bool, error) {
	b.mu.Lock()
	b.deleteMessageCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return false, b.sendErr
	}
	return true, nil
}

func (b *stubTelegramBot) AnswerCallbackQuery(_ context.Context, _ *tgbot.AnswerCallbackQueryParams) (bool, error) {
	b.mu.Lock()
	b.answerCallbackCalls++
	b.mu.Unlock()
	return true, nil
}

func (b *stubTelegramBot) SetMyCommands(_ context.Context, _ *tgbot.SetMyCommandsParams) (bool, error) {
	b.mu.Lock()
	b.setMyCommandsCalls++
	b.mu.Unlock()
	if b.sendErr != nil {
		return false, b.sendErr
	}
	return true, nil
}

func (b *stubTelegramBot) GetFile(_ context.Context, _ *tgbot.GetFileParams) (*models.File, error) {
	b.mu.Lock()
	b.getFileCalls++
	b.mu.Unlock()
	if b.getFileErr != nil {
		return nil, b.getFileErr
	}
	return b.file, nil
}

func (b *stubTelegramBot) FileDownloadLink(f *models.File) string {
	return "https://test.example.com/file/" + f.FilePath
}

func (b *stubTelegramBot) SetMessageReaction(_ context.Context, _ *tgbot.SetMessageReactionParams) (bool, error) {
	b.mu.Lock()
	b.setReactionCalls++
	b.mu.Unlock()
	return true, nil
}

func (b *stubTelegramBot) SendMessageCallCount() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.sendMessageCalls
}

func (b *stubTelegramBot) SendChatActionCallCount() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.sendChatActionCalls
}

func (b *stubTelegramBot) GetFileCallCount() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.getFileCalls
}

func TestPlatformStart_RetriesInBackgroundUntilConnected(t *testing.T) {
	var attempts atomic.Int32
	readyCh := make(chan struct{}, 1)
	stubBot := newStubTelegramBot()
	me := &models.User{ID: 42, Username: "mybot"}

	p := &Platform{
		token:      "token",
		httpClient: &http.Client{},
		newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
			if attempts.Add(1) == 1 {
				return nil, nil, nil, errors.New("dial failed")
			}
			return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
		},
		newBackoffTimer: immediateTimer,
	}
	p.SetLifecycleHandler(testLifecycleHandler{
		onReady: func(core.Platform) {
			readyCh <- struct{}{}
		},
	})

	if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
		t.Fatalf("Start: %v", err)
	}
	defer func() {
		if err := p.Stop(); err != nil {
			t.Fatalf("Stop: %v", err)
		}
	}()

	select {
	case <-readyCh:
	case <-time.After(time.Second):
		t.Fatal("ready callback not observed")
	}

	if got := attempts.Load(); got < 2 {
		t.Fatalf("attempts = %d, want >= 2", got)
	}
}

func TestPlatformStart_InitialConnectFailureEmitsUnavailableOnceBeforeReady(t *testing.T) {
	var attempts atomic.Int32
	var unavailableCount atomic.Int32
	readyCh := make(chan struct{}, 1)
	stubBot := newStubTelegramBot()
	me := &models.User{ID: 42, Username: "mybot"}

	p := &Platform{
		token:      "token",
		httpClient: &http.Client{},
		newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
			if attempts.Add(1) <= 2 {
				return nil, nil, nil, errors.New("dial failed")
			}
			return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
		},
		newBackoffTimer: immediateTimer,
	}
	p.SetLifecycleHandler(testLifecycleHandler{
		onReady: func(core.Platform) {
			readyCh <- struct{}{}
		},
		onUnavailable: func(core.Platform, error) {
			unavailableCount.Add(1)
		},
	})

	if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
		t.Fatalf("Start: %v", err)
	}
	defer func() {
		if err := p.Stop(); err != nil {
			t.Fatalf("Stop: %v", err)
		}
	}()

	select {
	case <-readyCh:
	case <-time.After(time.Second):
		t.Fatal("ready callback not observed")
	}

	if got := unavailableCount.Load(); got != 1 {
		t.Fatalf("unavailable callbacks = %d, want 1", got)
	}
}

func TestPlatformDisconnectedSendPathsReturnNotConnected(t *testing.T) {
	p := &Platform{token: "token", httpClient: &http.Client{}}
	ctx := context.Background()
	rctx := replyContext{chatID: 1, threadID: 0, messageID: 2}

	tests := []struct {
		name string
		run  func() error
	}{
		{name: "Reply", run: func() error { return p.Reply(ctx, rctx, "hello") }},
		{name: "Send", run: func() error { return p.Send(ctx, rctx, "hello") }},
		{name: "SendImage", run: func() error { return p.SendImage(ctx, rctx, core.ImageAttachment{Data: []byte("img")}) }},
		{name: "SendFile", run: func() error { return p.SendFile(ctx, rctx, core.FileAttachment{Data: []byte("file")}) }},
		{name: "SendWithButtons", run: func() error {
			return p.SendWithButtons(ctx, rctx, "hello", [][]core.ButtonOption{{{Text: "A", Data: "a"}}})
		}},
		{name: "SendPreviewStart", run: func() error {
			_, err := p.SendPreviewStart(ctx, rctx, "preview")
			return err
		}},
		{name: "UpdateMessage", run: func() error {
			return p.UpdateMessage(ctx, &telegramPreviewHandle{chatID: 1, messageID: 2}, "preview")
		}},
		{name: "DeletePreviewMessage", run: func() error {
			return p.DeletePreviewMessage(ctx, &telegramPreviewHandle{chatID: 1, messageID: 2})
		}},
		{name: "RegisterCommands", run: func() error {
			return p.RegisterCommands([]core.BotCommandInfo{{Command: "help", Description: "help"}})
		}},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			err := tt.run()
			if err == nil {
				t.Fatal("expected error, got nil")
			}
			if !strings.Contains(err.Error(), "not connected") {
				t.Fatalf("error = %q, want to contain %q", err.Error(), "not connected")
			}
		})
	}

	stop := p.StartTyping(ctx, rctx)
	stop()
}

func TestPlatformLateReadyIgnoredAfterStop(t *testing.T) {
	connectStarted := make(chan struct{})
	releaseConnect := make(chan struct{})
	connectDone := make(chan struct{})
	readyCh := make(chan struct{}, 1)
	unavailableCh := make(chan error, 1)
	stubBot := newStubTelegramBot()
	me := &models.User{ID: 42, Username: "latebot"}

	p := &Platform{
		token:      "token",
		httpClient: &http.Client{},
		newBot: func(_ string, _ func(context.Context, *models.Update), _ *http.Client) (telegramBot, *models.User, func(context.Context), error) {
			close(connectStarted)
			defer close(connectDone)
			<-releaseConnect
			return stubBot, me, func(ctx context.Context) { <-ctx.Done() }, nil
		},
		newBackoffTimer: immediateTimer,
	}
	p.SetLifecycleHandler(testLifecycleHandler{
		onReady: func(core.Platform) {
			readyCh <- struct{}{}
		},
		onUnavailable: func(_ core.Platform, err error) {
			unavailableCh <- err
		},
	})

	if err := p.Start(func(core.Platform, *core.Message) {}); err != nil {
		t.Fatalf("Start: %v", err)
	}
	<-connectStarted
	if err := p.Stop(); err != nil {
		t.Fatalf("Stop: %v", err)
	}
	close(releaseConnect)
	<-connectDone

	select {
	case <-readyCh:
		t.Fatal("unexpected ready callback after Stop")
	case err := <-unavailableCh:
		t.Fatalf("unexpected unavailable callback after Stop: %v", err)
	case <-time.After(100 * time.Millisecond):
	}
}

func TestPlatformStartTypingSwitchesToCurrentBotAfterReconnect(t *testing.T) {
	oldBot := newStubTelegramBot()
	newBot := newStubTelegramBot()
	ticker := newStubTypingTicker()

	p := &Platform{
		token:      "token",
		httpClient: &http.Client{},
		newTypingTicker: func(time.Duration) typingTicker {
			return ticker
		},
	}

	me := &models.User{ID: 42, Username: "old"}
	p.publishBot(oldBot, me)

	ctx, cancel := context.WithCancel(context.Background())
	stop := p.StartTyping(ctx, replyContext{chatID: 1, threadID: 0, messageID: 2})
	defer func() {
		stop()
		cancel()
	}()

	if got := oldBot.SendChatActionCallCount(); got != 1 {
		t.Fatalf("old bot action calls after initial typing = %d, want 1", got)
	}

	me2 := &models.User{ID: 42, Username: "new"}
	p.publishBot(newBot, me2)

	ticker.ch <- time.Now()
	time.Sleep(20 * time.Millisecond)

	if got := oldBot.SendChatActionCallCount(); got != 1 {
		t.Fatalf("old bot action calls after reconnect tick = %d, want 1", got)
	}
	if got := newBot.SendChatActionCallCount(); got != 1 {
		t.Fatalf("new bot action calls after reconnect tick = %d, want 1", got)
	}
}

func TestRetryLogMessage_DistinguishesFailureModes(t *testing.T) {
	tests := []struct {
		name  string
		cause retryCause
		want  string
	}{
		{name: "initial connect failure", cause: retryCauseInitialConnectFailure, want: "telegram: initial connection failed, retrying"},
		{name: "reconnect failure", cause: retryCauseReconnectFailure, want: "telegram: reconnect failed, retrying"},
		{name: "connection lost", cause: retryCauseConnectionLost, want: "telegram: connection lost, retrying"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := retryLogMessage(tt.cause); got != tt.want {
				t.Fatalf("retryLogMessage(%v) = %q, want %q", tt.cause, got, tt.want)
			}
		})
	}
}

func TestExtractEntityText(t *testing.T) {
	tests := []struct {
		name   string
		text   string
		offset int
		length int
		want   string
	}{
		{
			name:   "ASCII only",
			text:   "hello @bot world",
			offset: 6,
			length: 4,
			want:   "@bot",
		},
		{
			name:   "Chinese before mention",
			text:   "你好 @mybot 你好",
			offset: 3,
			length: 6,
			want:   "@mybot",
		},
		{
			// 👍 is U+1F44D = surrogate pair (2 UTF-16 code units)
			// "Hi " = 3, "👍" = 2, " " = 1 → @mybot starts at UTF-16 offset 6
			name:   "emoji before mention (surrogate pair)",
			text:   "Hi 👍 @mybot test",
			offset: 6,
			length: 6,
			want:   "@mybot",
		},
		{
			name:   "multiple emoji before mention",
			text:   "🎉🎊 @testbot",
			offset: 5,
			length: 8,
			want:   "@testbot",
		},
		{
			name:   "out of range returns empty",
			text:   "short",
			offset: 10,
			length: 5,
			want:   "",
		},
		{
			name:   "negative offset returns empty",
			text:   "hello",
			offset: -1,
			length: 3,
			want:   "",
		},
		{
			name:   "negative length returns empty",
			text:   "hello",
			offset: 0,
			length: -1,
			want:   "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := extractEntityText(tt.text, tt.offset, tt.length)
			if got != tt.want {
				t.Errorf("extractEntityText(%q, %d, %d) = %q, want %q",
					tt.text, tt.offset, tt.length, got, tt.want)
			}
		})
	}
}

func TestSendAudioRejectsInvalidReplyContext(t *testing.T) {
	p := &Platform{}

	err := p.SendAudio(context.Background(), "bad-context", []byte("data"), "mp3")
	if err == nil {
		t.Fatal("expected error for invalid reply context")
	}
	if !strings.Contains(err.Error(), "telegram: SendAudio: invalid reply context type") {
		t.Fatalf("unexpected error: %v", err)
	}
}

func TestSendAudioReturnsConversionErrorForWAV(t *testing.T) {
	orig := telegramConvertAudioToOpus
	t.Cleanup(func() { telegramConvertAudioToOpus = orig })

	telegramConvertAudioToOpus = func(_ context.Context, _ []byte, _ string) ([]byte, error) {
		return nil, errors.New("mock conversion failure")
	}

	stubBot := newStubTelegramBot()
	p := &Platform{}
	p.bot = stubBot
	p.selfUser = &models.User{ID: 1, Username: "testbot"}

	err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("wav-data"), "wav")
	if err == nil {
		t.Fatal("expected conversion error")
	}
	if !strings.Contains(err.Error(), "telegram: SendAudio: convert wav to opus") {
		t.Fatalf("unexpected error prefix: %v", err)
	}
	if !strings.Contains(err.Error(), "mock conversion failure") {
		t.Fatalf("expected wrapped conversion error, got: %v", err)
	}
}

func TestTruncateTelegramBotDescription_UTF8Safe(t *testing.T) {
	t.Parallel()
	cjk := strings.Repeat("你", 200)
	out := truncateTelegramBotDescription(cjk)
	if !utf8.ValidString(out) {
		t.Fatal("invalid UTF-8 from CJK description")
	}
	if got, max := utf8.RuneCountInString(out), telegramBotCommandDescriptionLimit; got > max {
		t.Fatalf("rune count %d > %d", got, max)
	}

	long := strings.Repeat("b", 60)
	out2 := truncateTelegramBotDescription(long)
	if want := telegramBotCommandDescriptionLimit; utf8.RuneCountInString(out2) != want {
		t.Fatalf("ascii truncation: got %d runes want %d", utf8.RuneCountInString(out2), want)
	}
	if !utf8.ValidString(out2) {
		t.Fatal("invalid UTF-8 after ascii truncation")
	}
}

func TestTruncateForLog_UTF8Safe(t *testing.T) {
	t.Parallel()
	s := strings.Repeat("世", 50) // 50 runes
	out := truncateForLog(s, 10)
	if !utf8.ValidString(out) {
		t.Fatal("invalid UTF-8")
	}
	if utf8.RuneCountInString(out) != 13 { // 10 + "..."
		t.Fatalf("got %d runes", utf8.RuneCountInString(out))
	}
}

func TestSendAudioMP3PrefersVoice(t *testing.T) {
	var paths []string
	p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
		paths = append(paths, r.URL.Path)
		fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
	})

	if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("mp3-data"), "mp3"); err != nil {
		t.Fatalf("SendAudio returned error: %v", err)
	}

	if len(paths) != 1 {
		t.Fatalf("request count = %d, want 1", len(paths))
	}
	if !strings.HasSuffix(paths[0], "/sendVoice") {
		t.Fatalf("path = %q, want sendVoice", paths[0])
	}
}

func TestSendAudioWAVConvertsToVoice(t *testing.T) {
	orig := telegramConvertAudioToOpus
	t.Cleanup(func() { telegramConvertAudioToOpus = orig })

	var (
		paths      []string
		converted  bool
		gotFormat  string
		gotPayload []byte
	)
	telegramConvertAudioToOpus = func(_ context.Context, audio []byte, format string) ([]byte, error) {
		converted = true
		gotFormat = format
		gotPayload = append([]byte(nil), audio...)
		return []byte("converted-opus"), nil
	}

	p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
		paths = append(paths, r.URL.Path)
		fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
	})

	if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("wav-data"), "wav"); err != nil {
		t.Fatalf("SendAudio returned error: %v", err)
	}

	if !converted {
		t.Fatal("expected wav input to be converted before sendVoice")
	}
	if gotFormat != "wav" {
		t.Fatalf("converter format = %q, want wav", gotFormat)
	}
	if string(gotPayload) != "wav-data" {
		t.Fatalf("converter payload = %q, want wav-data", gotPayload)
	}
	if len(paths) != 1 || !strings.HasSuffix(paths[0], "/sendVoice") {
		t.Fatalf("paths = %v, want only sendVoice", paths)
	}
}

func TestSendAudioFallsBackToSendAudioForMP3(t *testing.T) {
	var paths []string
	p := newTelegramTestPlatform(t, func(w http.ResponseWriter, r *http.Request) {
		paths = append(paths, r.URL.Path)
		if strings.HasSuffix(r.URL.Path, "/sendVoice") {
			fmt.Fprint(w, `{"ok":false,"error_code":400,"description":"voice rejected"}`)
			return
		}
		fmt.Fprint(w, `{"ok":true,"result":{"message_id":1}}`)
	})

	if err := p.SendAudio(context.Background(), replyContext{chatID: 123}, []byte("mp3-data"), "mp3"); err != nil {
		t.Fatalf("SendAudio returned error: %v", err)
	}

	if len(paths) != 2 {
		t.Fatalf("request count = %d, want 2", len(paths))
	}
	if !strings.HasSuffix(paths[0], "/sendVoice") || !strings.HasSuffix(paths[1], "/sendAudio") {
		t.Fatalf("paths = %v, want sendVoice then sendAudio", paths)
	}
}

func TestBuildSessionKey(t *testing.T) {
	tests := []struct {
		name   string
		shared bool
		chatID int64
		thread int
		userID int64
		want   string
	}{
		{name: "private no topic", shared: false, chatID: 100, thread: 0, userID: 7, want: "telegram:100:7"},
		{name: "private with topic", shared: false, chatID: 100, thread: 42, userID: 7, want: "telegram:100:42:7"},
		{name: "shared no topic", shared: true, chatID: 100, thread: 0, userID: 7, want: "telegram:100"},
		{name: "shared with topic", shared: true, chatID: 100, thread: 42, userID: 7, want: "telegram:100:42"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			p := &Platform{shareSessionInChannel: tt.shared}
			got := p.buildSessionKey(tt.chatID, tt.thread, tt.userID)
			if got != tt.want {
				t.Fatalf("buildSessionKey(%d, %d, %d) = %q, want %q", tt.chatID, tt.thread, tt.userID, got, tt.want)
			}
		})
	}
}

func TestReconstructReplyCtx(t *testing.T) {
	tests := []struct {
		name     string
		shared   bool
		key      string
		wantChat int64
		wantThr  int
		wantErr  bool
	}{
		{name: "shared no topic", shared: true, key: "telegram:100", wantChat: 100, wantThr: 0},
		{name: "shared with topic", shared: true, key: "telegram:100:42", wantChat: 100, wantThr: 42},
		{name: "per-user no topic", shared: false, key: "telegram:100:7", wantChat: 100, wantThr: 0},
		{name: "per-user with topic", shared: false, key: "telegram:100:42:7", wantChat: 100, wantThr: 42},
		{name: "invalid prefix", shared: false, key: "slack:100:7", wantErr: true},
		{name: "too short", shared: false, key: "telegram", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			p := &Platform{shareSessionInChannel: tt.shared}
			rctx, err := p.ReconstructReplyCtx(tt.key)
			if tt.wantErr {
				if err == nil {
					t.Fatal("expected error, got nil")
				}
				return
			}
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			rc := rctx.(replyContext)
			if rc.chatID != tt.wantChat {
				t.Fatalf("chatID = %d, want %d", rc.chatID, tt.wantChat)
			}
			if rc.threadID != tt.wantThr {
				t.Fatalf("threadID = %d, want %d", rc.threadID, tt.wantThr)
			}
		})
	}
}

func TestIsDirectedAtBot(t *testing.T) {
	p := &Platform{token: "token", httpClient: &http.Client{}}
	p.selfUser = &models.User{ID: 42, Username: "mybot"}

	tests := []struct {
		name string
		msg  *models.Message
		want bool
	}{
		{
			name: "command without @suffix",
			msg: &models.Message{
				Text: "/help",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
				Entities: []models.MessageEntity{
					{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 5},
				},
			},
			want: true,
		},
		{
			name: "command @mybot",
			msg: &models.Message{
				Text: "/help@mybot",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
				Entities: []models.MessageEntity{
					{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 11},
				},
			},
			want: true,
		},
		{
			name: "command @otherbot",
			msg: &models.Message{
				Text: "/help@otherbot",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
				Entities: []models.MessageEntity{
					{Type: models.MessageEntityTypeBotCommand, Offset: 0, Length: 14},
				},
			},
			want: false,
		},
		{
			name: "@mention in text",
			msg: &models.Message{
				Text: "hey @mybot do something",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
				Entities: []models.MessageEntity{
					{Type: models.MessageEntityTypeMention, Offset: 4, Length: 6},
				},
			},
			want: true,
		},
		{
			name: "reply to bot message",
			msg: &models.Message{
				Text: "yes do it",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
				ReplyToMessage: &models.Message{
					From: &models.User{ID: 42},
				},
			},
			want: true,
		},
		{
			name: "plain text not directed",
			msg: &models.Message{
				Text: "hello everyone",
				Chat: models.Chat{ID: 1, Type: models.ChatTypeGroup},
			},
			want: false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := p.isDirectedAtBot(tt.msg)
			if got != tt.want {
				t.Fatalf("isDirectedAtBot() = %v, want %v", got, tt.want)
			}
		})
	}
}

func TestHandleMessageWithForumTopic(t *testing.T) {
	handled := make(chan *core.Message, 1)
	p := &Platform{
		token:         "token",
		httpClient:    &http.Client{},
		groupReplyAll: true,
	}
	p.handler = func(_ core.Platform, msg *core.Message) {
		handled <- msg
	}
	stubBot := newStubTelegramBot()
	p.bot = stubBot
	p.selfUser = &models.User{ID: 42, Username: "mybot"}

	msg := &models.Message{
		ID:              10,
		MessageThreadID: 55,
		Text:            "hello from topic",
		Date:            int(time.Now().Unix()),
		From:            &models.User{ID: 7, Username: "alice"},
		Chat: models.Chat{
			ID:      100,
			Type:    models.ChatTypeSupergroup,
			Title:   "Test Group",
			IsForum: true,
		},
	}

	p.handleMessage(context.Background(), msg)

	select {
	case got := <-handled:
		if got.SessionKey != "telegram:100:55:7" {
			t.Fatalf("SessionKey = %q, want %q", got.SessionKey, "telegram:100:55:7")
		}
		rc := got.ReplyCtx.(replyContext)
		if rc.threadID != 55 {
			t.Fatalf("threadID = %d, want 55", rc.threadID)
		}
	case <-time.After(time.Second):
		t.Fatal("message not handled")
	}
}

func TestHandleMessagePrivateTopicUsesThreadID(t *testing.T) {
	handled := make(chan *core.Message, 1)
	p := &Platform{
		token:         "token",
		httpClient:    &http.Client{},
		groupReplyAll: true,
	}
	p.handler = func(_ core.Platform, msg *core.Message) {
		handled <- msg
	}
	stubBot := newStubTelegramBot()
	p.bot = stubBot
	p.selfUser = &models.User{ID: 42, Username: "mybot"}

	msg := &models.Message{
		ID:              10,
		MessageThreadID: 55,
		Text:            "hello",
		Date:            int(time.Now().Unix()),
		From:            &models.User{ID: 7, Username: "alice"},
		Chat: models.Chat{
			ID:      100,
			Type:    models.ChatTypePrivate,
			IsForum: false,
		},
	}

	p.handleMessage(context.Background(), msg)

	select {
	case got := <-handled:
		if got.SessionKey != "telegram:100:55:7" {
			t.Fatalf("SessionKey = %q, want %q", got.SessionKey, "telegram:100:55:7")
		}
		rc := got.ReplyCtx.(replyContext)
		if rc.threadID != 55 {
			t.Fatalf("threadID = %d, want 55", rc.threadID)
		}
	case <-time.After(time.Second):
		t.Fatal("message not handled")
	}
}

func newTelegramTestPlatform(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *Platform {
	t.Helper()

	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if strings.HasSuffix(r.URL.Path, "/getMe") {
			fmt.Fprint(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test","username":"testbot"}}`)
			return
		}
		handler(w, r)
	}))
	t.Cleanup(server.Close)

	b, err := tgbot.New("TEST_TOKEN",
		tgbot.WithServerURL(server.URL),
		tgbot.WithHTTPClient(5*time.Second, server.Client()),
	)
	if err != nil {
		t.Fatalf("tgbot.New returned error: %v", err)
	}

	return &Platform{
		bot:        b,
		selfUser:   &models.User{ID: 1, Username: "testbot"},
		httpClient: server.Client(),
	}
}

func TestNewProgressStyleDefault(t *testing.T) {
	p, err := New(map[string]any{"token": "test-token"})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	tg, ok := p.(*Platform)
	if !ok {
		t.Fatalf("expected *Platform, got %T", p)
	}
	if got := tg.ProgressStyle(); got != "compact" {
		t.Fatalf("default ProgressStyle() = %q, want %q", got, "compact")
	}
}

func TestNewProgressStyleOptions(t *testing.T) {
	tests := []struct {
		name     string
		input    any
		wantErr  bool
		wantMode string
	}{
		{name: "explicit compact", input: "compact", wantMode: "compact"},
		{name: "explicit legacy", input: "legacy", wantMode: "legacy"},
		{name: "card normalized to compact", input: "card", wantMode: "compact"},
		{name: "uppercase tolerated", input: "COMPACT", wantMode: "compact"},
		{name: "whitespace tolerated", input: "  legacy  ", wantMode: "legacy"},
		{name: "empty string keeps default", input: "", wantMode: "compact"},
		{name: "invalid value rejected", input: "fancy", wantErr: true},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			p, err := New(map[string]any{
				"token":          "test-token",
				"progress_style": tc.input,
			})
			if tc.wantErr {
				if err == nil {
					t.Fatalf("expected error for input %v, got nil", tc.input)
				}
				if !strings.Contains(err.Error(), "invalid progress_style") {
					t.Fatalf("error = %q, want substring 'invalid progress_style'", err.Error())
				}
				return
			}
			if err != nil {
				t.Fatalf("New: %v", err)
			}
			tg := p.(*Platform)
			if got := tg.ProgressStyle(); got != tc.wantMode {
				t.Fatalf("ProgressStyle() = %q, want %q", got, tc.wantMode)
			}
		})
	}
}

// TestProgressStyleProviderInterface verifies *Platform satisfies the
// core.ProgressStyleProvider interface so the engine's
// progressStyleForPlatform() picks up our implementation rather than
// falling back to legacy.
func TestProgressStyleProviderInterface(t *testing.T) {
	var p core.Platform = &Platform{progressStyle: "compact"}
	provider, ok := p.(interface{ ProgressStyle() string })
	if !ok {
		t.Fatalf("*Platform does not satisfy ProgressStyle() interface")
	}
	if got := provider.ProgressStyle(); got != "compact" {
		t.Fatalf("ProgressStyle() = %q, want compact", got)
	}
}

