package feishu

import (
	"context"
	"encoding/json"
	"errors"
	"strings"
	"testing"

	larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"

	"github.com/memohai/memoh/internal/channel"
)

type fakeProcessingReactionGateway struct {
	addCalls    []struct{ messageID, reactionType string }
	removeCalls []struct{ messageID, reactionID string }
	addResponse []struct {
		reactionID string
		err        error
	}
	removeErr error
}

func (g *fakeProcessingReactionGateway) Add(ctx context.Context, messageID, reactionType string) (string, error) {
	g.addCalls = append(g.addCalls, struct{ messageID, reactionType string }{
		messageID:    messageID,
		reactionType: reactionType,
	})
	if len(g.addResponse) == 0 {
		return "reaction-default", nil
	}
	resp := g.addResponse[0]
	g.addResponse = g.addResponse[1:]
	return resp.reactionID, resp.err
}

func (g *fakeProcessingReactionGateway) Remove(ctx context.Context, messageID, reactionID string) error {
	g.removeCalls = append(g.removeCalls, struct{ messageID, reactionID string }{
		messageID:  messageID,
		reactionID: reactionID,
	})
	return g.removeErr
}

func TestResolveFeishuReceiveID(t *testing.T) {
	t.Parallel()

	cases := []struct {
		raw       string
		wantID    string
		wantType  string
		shouldErr bool
	}{
		{raw: "open_id:ou_123", wantID: "ou_123", wantType: "open_id"},
		{raw: "user_id:uu_123", wantID: "uu_123", wantType: "user_id"},
		{raw: "chat_id:oc_123", wantID: "oc_123", wantType: "chat_id"},
		{raw: "ou_999", wantID: "ou_999", wantType: "open_id"},
		{raw: "", shouldErr: true},
	}
	for _, tc := range cases {
		id, idType, err := resolveFeishuReceiveID(tc.raw)
		if tc.shouldErr {
			if err == nil {
				t.Fatalf("expected error for %q", tc.raw)
			}
			continue
		}
		if err != nil {
			t.Fatalf("unexpected error for %q: %v", tc.raw, err)
		}
		if id != tc.wantID || idType != tc.wantType {
			t.Fatalf("unexpected result for %q: %s %s", tc.raw, id, idType)
		}
	}
}

func TestExtractFeishuInboundP2P(t *testing.T) {
	t.Parallel()

	text := `{"text":"hi"}`
	msgType := larkim.MsgTypeText
	chatType := "p2p"
	chatID := "oc_1"
	userID := "u_1"
	openID := "ou_1"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &text,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
			Sender: &larkim.EventSender{
				SenderId: &larkim.UserId{
					UserId: &userID,
					OpenId: &openID,
				},
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if got.Message.PlainText() != "hi" {
		t.Fatalf("unexpected text: %s", got.Message.PlainText())
	}
	if got.ReplyTarget != "ou_1" {
		t.Fatalf("unexpected reply target: %s", got.ReplyTarget)
	}
	if got.Sender.DisplayName != "" {
		t.Fatalf("expected empty sender display name, got: %s", got.Sender.DisplayName)
	}
	if got.Sender.SubjectID != "ou_1" {
		t.Fatalf("unexpected sender subject id: %s", got.Sender.SubjectID)
	}
	if got.Sender.Attribute("open_id") != "ou_1" {
		t.Fatalf("unexpected sender open_id: %s", got.Sender.Attribute("open_id"))
	}
	if got.Sender.Attribute("user_id") != "u_1" {
		t.Fatalf("unexpected sender user_id: %s", got.Sender.Attribute("user_id"))
	}
	if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned {
		t.Fatalf("unexpected mention flag for p2p message")
	}
}

func TestExtractFeishuInboundGroup(t *testing.T) {
	t.Parallel()

	text := `{"text":"hi"}`
	msgType := larkim.MsgTypeText
	chatType := "group"
	chatID := "oc_2"
	userID := "u_2"
	openID := "ou_2"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &text,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
			Sender: &larkim.EventSender{
				SenderId: &larkim.UserId{
					UserId: &userID,
					OpenId: &openID,
				},
			},
		},
	}
	got := extractFeishuInbound(event, "ou_bot")
	if got.ReplyTarget != "chat_id:oc_2" {
		t.Fatalf("unexpected reply target: %s", got.ReplyTarget)
	}
	if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned {
		t.Fatalf("unexpected mention flag for group message without mentions")
	}
}

func TestExtractFeishuInboundNonText(t *testing.T) {
	t.Parallel()

	msgType := "image"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if got.Message.PlainText() != "" {
		t.Fatalf("expected empty text, got %s", got.Message.PlainText())
	}
}

func TestExtractFeishuInboundImageAttachmentReference(t *testing.T) {
	t.Parallel()

	content := `{"image_key":"img_1"}`
	msgType := larkim.MsgTypeImage
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &content,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if len(got.Message.Attachments) != 1 {
		t.Fatalf("expected one attachment, got %d", len(got.Message.Attachments))
	}
	att := got.Message.Attachments[0]
	if att.Type != channel.AttachmentImage {
		t.Fatalf("unexpected attachment type: %s", att.Type)
	}
	if att.PlatformKey != "img_1" {
		t.Fatalf("unexpected platform key: %s", att.PlatformKey)
	}
	if att.SourcePlatform != Type.String() {
		t.Fatalf("unexpected source platform: %s", att.SourcePlatform)
	}
	if att.Metadata == nil || att.Metadata["message_id"] == nil {
		t.Fatal("expected message_id in attachment metadata")
	}
}

func TestExtractFeishuInboundFileAttachmentInfersVideoType(t *testing.T) {
	t.Parallel()

	content := `{"file_key":"file_1","file_name":"clip.mp4","mime_type":"video/mp4"}`
	msgType := larkim.MsgTypeFile
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &content,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if len(got.Message.Attachments) != 1 {
		t.Fatalf("expected one attachment, got %d", len(got.Message.Attachments))
	}
	att := got.Message.Attachments[0]
	if att.Type != channel.AttachmentVideo {
		t.Fatalf("expected inferred video type, got %s", att.Type)
	}
	if att.Mime != "video/mp4" {
		t.Fatalf("expected normalized mime video/mp4, got %s", att.Mime)
	}
}

func TestFeishuDescriptorIncludesStreamingAndMedia(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	caps := adapter.Descriptor().Capabilities
	if !caps.Streaming {
		t.Fatal("expected streaming capability")
	}
	if !caps.Media {
		t.Fatal("expected media capability")
	}
}

func TestFeishuResolveAttachmentRequiresPlatformKey(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	_, err := adapter.ResolveAttachment(context.Background(), channel.ChannelConfig{}, channel.Attachment{})
	if err == nil {
		t.Fatal("expected error when platform_key is missing")
	}
	if !strings.Contains(err.Error(), "platform_key") {
		t.Fatalf("expected platform_key error, got: %v", err)
	}
}

func TestFeishuResolveAttachmentRequiresMessageID(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	_, err := adapter.ResolveAttachment(context.Background(), channel.ChannelConfig{}, channel.Attachment{
		PlatformKey: "img_123",
	})
	if err == nil {
		t.Fatal("expected error when message_id is missing")
	}
	if !strings.Contains(err.Error(), "message_id") {
		t.Fatalf("expected message_id error, got: %v", err)
	}
}

func TestIsFeishuImageAttachment(t *testing.T) {
	t.Parallel()

	if !isFeishuImageAttachment(channel.Attachment{Type: channel.AttachmentImage}) {
		t.Fatal("expected image type to be identified as image")
	}
	if !isFeishuImageAttachment(channel.Attachment{Type: channel.AttachmentGIF}) {
		t.Fatal("expected gif type to be identified as image")
	}
	if !isFeishuImageAttachment(channel.Attachment{Mime: "image/jpeg"}) {
		t.Fatal("expected image/ mime to be identified as image")
	}
	if isFeishuImageAttachment(channel.Attachment{Type: channel.AttachmentFile}) {
		t.Fatal("expected file type to not be identified as image")
	}
	if isFeishuImageAttachment(channel.Attachment{Type: channel.AttachmentAudio}) {
		t.Fatal("expected audio type to not be identified as image")
	}
}

func TestBuildFeishuStreamCardContent(t *testing.T) {
	t.Parallel()

	payload, err := buildFeishuStreamCardContent("hello")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	var parsed map[string]any
	if err := json.Unmarshal([]byte(payload), &parsed); err != nil {
		t.Fatalf("unexpected json error: %v", err)
	}
	cfg, ok := parsed["config"].(map[string]any)
	if !ok {
		t.Fatalf("missing config: %+v", parsed)
	}
	value, ok := cfg["update_multi"].(bool)
	if !ok || !value {
		t.Fatalf("expected update_multi=true, got: %#v", cfg["update_multi"])
	}
}

func TestBuildFeishuStreamCardContentWithState(t *testing.T) {
	t.Parallel()

	payload, err := buildFeishuStreamCardContent("answer body")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if !strings.Contains(payload, "answer body") {
		t.Fatalf("expected stream body content in payload: %s", payload)
	}
	if strings.Contains(payload, "Tools:**") || strings.Contains(payload, "Calling:") {
		t.Fatalf("expected no tool/process panel in payload: %s", payload)
	}
}

func TestNormalizeFeishuStreamText(t *testing.T) {
	t.Parallel()

	if got := normalizeFeishuStreamText("   "); got != feishuStreamThinkingText {
		t.Fatalf("unexpected thinking text: %s", got)
	}
	long := strings.Repeat("a", feishuStreamMaxRunes+100)
	got := normalizeFeishuStreamText(long)
	if len([]rune(got)) > feishuStreamMaxRunes+4 {
		t.Fatalf("expected truncated text, got len=%d", len([]rune(got)))
	}
	if !strings.HasPrefix(got, "...\n") {
		t.Fatalf("expected truncation prefix, got: %s", got[:4])
	}
}

func TestProcessFeishuCardMarkdown(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name string
		in   string
		want string
	}{
		{"literal newline", "a\\nb", "a\nb"},
		{"atx h1", "# Title", "**Title**"},
		{"atx h2", "## Section", "**Section**"},
		{"atx h6", "###### Small", "**Small**"},
		{"heading with newline", "# Hi\n\nBody", "**Hi**\n\nBody"},
		{"no heading", "plain **bold**", "plain **bold**"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := processFeishuCardMarkdown(tt.in)
			if got != tt.want {
				t.Errorf("processFeishuCardMarkdown() = %q, want %q", got, tt.want)
			}
		})
	}
}

func TestExtractFeishuInboundMentionFallbackNoBotID(t *testing.T) {
	t.Parallel()

	text := `{"text":"@bot hi","mentions":[{"key":"@bot"}]}`
	msgType := larkim.MsgTypeText
	chatType := "group"
	chatID := "oc_3"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &text,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	mentioned, ok := got.Metadata["is_mentioned"].(bool)
	if !ok || !mentioned {
		t.Fatalf("expected mention flag to be true (fallback)")
	}
}

func TestExtractFeishuInboundMentionBotMatched(t *testing.T) {
	t.Parallel()

	text := `{"text":"hello"}`
	msgType := larkim.MsgTypeText
	chatType := "group"
	chatID := "oc_mention_event"
	botOpenID := "ou_bot_123"
	mention := larkim.NewMentionEventBuilder().
		Key("@_user_1").
		Id(larkim.NewUserIdBuilder().OpenId(botOpenID).Build()).
		Build()
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &text,
				ChatType:    &chatType,
				ChatId:      &chatID,
				Mentions:    []*larkim.MentionEvent{mention},
			},
		},
	}
	got := extractFeishuInbound(event, botOpenID)
	mentioned, ok := got.Metadata["is_mentioned"].(bool)
	if !ok || !mentioned {
		t.Fatalf("expected mention flag when bot is mentioned")
	}
}

func TestExtractFeishuInboundMentionOtherUserIgnored(t *testing.T) {
	t.Parallel()

	text := `{"text":"hello"}`
	msgType := larkim.MsgTypeText
	chatType := "group"
	chatID := "oc_mention_other"
	otherOpenID := "ou_other_user"
	mention := larkim.NewMentionEventBuilder().
		Key("@_user_1").
		Id(larkim.NewUserIdBuilder().OpenId(otherOpenID).Build()).
		Build()
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &text,
				ChatType:    &chatType,
				ChatId:      &chatID,
				Mentions:    []*larkim.MentionEvent{mention},
			},
		},
	}
	got := extractFeishuInbound(event, "ou_bot_123")
	if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned {
		t.Fatalf("expected no mention flag when another user is mentioned")
	}
}

func TestExtractFeishuInboundPostMentionFallback(t *testing.T) {
	t.Parallel()

	content := `{"title":"","content":[[{"tag":"at","user_name":"bot"},{"tag":"text","text":" hi"}]]}`
	msgType := larkim.MsgTypePost
	chatType := "group"
	chatID := "oc_post_1"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &content,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if got.Message.PlainText() == "" {
		t.Fatalf("expected post message to be converted into text")
	}
	mentioned, ok := got.Metadata["is_mentioned"].(bool)
	if !ok || !mentioned {
		t.Fatalf("expected mention flag for post message (fallback)")
	}
}

func TestExtractFeishuInboundPostMentionBotMatched(t *testing.T) {
	t.Parallel()

	botOpenID := "ou_bot_123"
	content := `{"title":"","content":[[{"tag":"at","user_id":"ou_bot_123"},{"tag":"text","text":" hi"}]]}`
	msgType := larkim.MsgTypePost
	chatType := "group"
	chatID := "oc_post_bot"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &content,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
		},
	}
	got := extractFeishuInbound(event, botOpenID)
	mentioned, ok := got.Metadata["is_mentioned"].(bool)
	if !ok || !mentioned {
		t.Fatalf("expected mention flag for post with bot user_id")
	}
}

func TestExtractFeishuInboundPostRootContent(t *testing.T) {
	t.Parallel()

	// Feishu event payload uses root-level content
	content := `{"title":"","content":[[{"tag":"img","image_key":"img_v3_02uv_81bc4785-24d6-4fe2-b841-c3c4c691fc0g","width":1438,"height":810}],[{"tag":"text","text":"这是什么作品","style":[]}]]}`
	msgType := larkim.MsgTypePost
	chatType := "p2p"
	chatID := "oc_eb2b5e623f3a21e288fce40878564f8e"
	msgID := "om_x100b5606f7fc6ca4b376e5432634210"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageId:   &msgID,
				MessageType: &msgType,
				Content:     &content,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
		},
	}
	got := extractFeishuInbound(event, "")
	if got.Message.PlainText() != "这是什么作品" {
		t.Fatalf("expected text 这是什么作品, got %q", got.Message.PlainText())
	}
	if len(got.Message.Attachments) != 1 {
		t.Fatalf("expected 1 attachment, got %d", len(got.Message.Attachments))
	}
	if got.Message.Attachments[0].PlatformKey != "img_v3_02uv_81bc4785-24d6-4fe2-b841-c3c4c691fc0g" {
		t.Fatalf("unexpected platform_key: %q", got.Message.Attachments[0].PlatformKey)
	}
}

func TestExtractFeishuInboundPostMentionOtherIgnored(t *testing.T) {
	t.Parallel()

	content := `{"title":"","content":[[{"tag":"at","user_id":"ou_someone_else"},{"tag":"text","text":" hi"}]]}`
	msgType := larkim.MsgTypePost
	chatType := "group"
	chatID := "oc_post_other"
	event := &larkim.P2MessageReceiveV1{
		Event: &larkim.P2MessageReceiveV1Data{
			Message: &larkim.EventMessage{
				MessageType: &msgType,
				Content:     &content,
				ChatType:    &chatType,
				ChatId:      &chatID,
			},
		},
	}
	got := extractFeishuInbound(event, "ou_bot_123")
	if mentioned, _ := got.Metadata["is_mentioned"].(bool); mentioned {
		t.Fatalf("expected no mention for post mentioning other user")
	}
}

func TestAddProcessingReactionFirstSuccess(t *testing.T) {
	t.Parallel()

	gateway := &fakeProcessingReactionGateway{
		addResponse: []struct {
			reactionID string
			err        error
		}{
			{reactionID: "reaction-1"},
		},
	}
	token, err := addProcessingReaction(context.Background(), gateway, "om_1", "Typing")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if token != "reaction-1" {
		t.Fatalf("expected token reaction-1, got %q", token)
	}
	if len(gateway.addCalls) != 1 {
		t.Fatalf("expected one add call, got %d", len(gateway.addCalls))
	}
	if gateway.addCalls[0].messageID != "om_1" || gateway.addCalls[0].reactionType != "Typing" {
		t.Fatalf("unexpected add params: %+v", gateway.addCalls[0])
	}
}

func TestAddProcessingReactionReturnsError(t *testing.T) {
	t.Parallel()

	gateway := &fakeProcessingReactionGateway{
		addResponse: []struct {
			reactionID string
			err        error
		}{
			{err: errors.New("invalid reaction type")},
		},
	}
	token, err := addProcessingReaction(context.Background(), gateway, "om_2", "INVALID")
	if err == nil {
		t.Fatal("expected error")
	}
	if token != "" {
		t.Fatalf("expected empty token, got %q", token)
	}
	if len(gateway.addCalls) != 1 {
		t.Fatalf("expected one add call, got %d", len(gateway.addCalls))
	}
	if gateway.addCalls[0].reactionType != "INVALID" {
		t.Fatalf("unexpected add call sequence: %+v", gateway.addCalls)
	}
}

func TestAddProcessingReactionNoMessageID(t *testing.T) {
	t.Parallel()

	gateway := &fakeProcessingReactionGateway{}
	token, err := addProcessingReaction(context.Background(), gateway, "", "Typing")
	if err != nil {
		t.Fatalf("expected no error for empty message id, got: %v", err)
	}
	if token != "" {
		t.Fatalf("expected empty token, got %q", token)
	}
	if len(gateway.addCalls) != 0 {
		t.Fatalf("expected no add calls, got %+v", gateway.addCalls)
	}
}

func TestRemoveProcessingReaction(t *testing.T) {
	t.Parallel()

	gateway := &fakeProcessingReactionGateway{}
	if err := removeProcessingReaction(context.Background(), gateway, "om_3", "reaction-3"); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(gateway.removeCalls) != 1 {
		t.Fatalf("expected one remove call, got %d", len(gateway.removeCalls))
	}
	if gateway.removeCalls[0].messageID != "om_3" || gateway.removeCalls[0].reactionID != "reaction-3" {
		t.Fatalf("unexpected remove params: %+v", gateway.removeCalls[0])
	}
}

func TestRemoveProcessingReactionNoopForEmptyToken(t *testing.T) {
	t.Parallel()

	gateway := &fakeProcessingReactionGateway{}
	if err := removeProcessingReaction(context.Background(), gateway, "om_4", ""); err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(gateway.removeCalls) != 0 {
		t.Fatalf("expected no remove calls, got %+v", gateway.removeCalls)
	}
}

func TestFeishuProcessingStartedNoSourceMessageID(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	handle, err := adapter.ProcessingStarted(
		context.Background(),
		channel.ChannelConfig{},
		channel.InboundMessage{},
		channel.ProcessingStatusInfo{},
	)
	if err != nil {
		t.Fatalf("expected no error for empty source message id, got: %v", err)
	}
	if handle.Token != "" {
		t.Fatalf("expected empty token, got %q", handle.Token)
	}
}

func TestFeishuProcessingStartedRequiresConfigWhenSourceMessageExists(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	_, err := adapter.ProcessingStarted(
		context.Background(),
		channel.ChannelConfig{},
		channel.InboundMessage{},
		channel.ProcessingStatusInfo{SourceMessageID: "om_5"},
	)
	if err == nil {
		t.Fatal("expected error when credentials are missing")
	}
}

func TestFeishuProcessingCompletedNoopWithoutToken(t *testing.T) {
	t.Parallel()

	adapter := NewFeishuAdapter(nil)
	err := adapter.ProcessingCompleted(
		context.Background(),
		channel.ChannelConfig{},
		channel.InboundMessage{},
		channel.ProcessingStatusInfo{SourceMessageID: "om_6"},
		channel.ProcessingStatusHandle{},
	)
	if err != nil {
		t.Fatalf("expected no error for empty token, got: %v", err)
	}
}
