package broadcast

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

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/pkg/tencentim"
	broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)

func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) })
	service.SetSenderProfileSource(fakeSenderProfileSource{profiles: map[int64]SenderProfile{
		42: {UserID: 42, Account: "160042", Nickname: "Sender", Avatar: "https://cdn.example/sender.png", AvatarFrameURL: "https://cdn.example/sender-frame.png"},
		43: {UserID: 43, Account: "160043", Nickname: "Receiver", Avatar: "https://cdn.example/receiver.png", AvatarFrameURL: "https://cdn.example/receiver-frame.png"},
	}})
	envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
		SenderUserId:    42,
		TargetUserId:    43,
		GiftId:          "rocket",
		GiftCount:       2,
		GiftValue:       999,
		VisibleRegionId: 1001,
		CommandId:       "cmd-send-gift-1",
		GiftName:        "Rocket",
		GiftIconUrl:     "https://cdn.example/rocket.png",
		GiftEffectTypes: []string{"animation", "global_broadcast"},
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "gift_broadcast:room-1001:cmd-send-gift-1" {
		t.Fatalf("unexpected result: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
		t.Fatalf("unexpected broadcast record: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("payload is not json: %v", err)
	}
	if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" {
		t.Fatalf("payload mismatch: %+v", payload)
	}
	sender, _ := payload["sender"].(map[string]any)
	receiver, _ := payload["receiver"].(map[string]any)
	if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" {
		t.Fatalf("payload user profile mismatch: sender=%+v receiver=%+v", sender, receiver)
	}

	again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("second HandleRoomEvent failed: %v", err)
	}
	if again.BroadcastCreated {
		t.Fatalf("second consume must be idempotent: %+v", again)
	}
}

func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil)
	envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{
		SenderUserId:    42,
		TargetUserId:    43,
		GiftId:          "tagged-gift",
		GiftCount:       1,
		GiftValue:       10,
		VisibleRegionId: 1001,
		CommandId:       "cmd-tagged-gift",
		GiftEffectTypes: []string{"animation", "global_broadcast"},
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent tagged gift failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated {
		t.Fatalf("tagged gift should create broadcast: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
		t.Fatalf("unexpected tagged gift broadcast record: %+v", record)
	}
}

func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
	envelope := mustPasswordChangedEnvelope(t, &roomeventsv1.RoomPasswordChanged{
		ActorUserId:     42,
		Locked:          true,
		VisibleRegionId: 1001,
	})

	result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope)
	if err != nil {
		t.Fatalf("HandleRoomEvent password changed failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "room_password_changed:"+envelope.GetEventId() {
		t.Fatalf("unexpected password broadcast result: %+v", result)
	}
	record := repository.records[result.BroadcastEventID]
	if record.GroupID != "hy_lalu_bc_r_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
		t.Fatalf("unexpected password broadcast record: %+v", record)
	}
	var payload map[string]any
	if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
		t.Fatalf("password payload is not json: %v", err)
	}
	if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != float64(42) {
		t.Fatalf("password payload mismatch: %+v", payload)
	}
	action, ok := payload["action"].(map[string]any)
	if !ok || action["type"] != "refresh_room" || action["room_id"] != "room-1001" {
		t.Fatalf("password payload action mismatch: %+v", payload["action"])
	}
}

func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
	service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
	belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
	result, err := service.HandleRoomEvent(context.Background(), belowThreshold)
	if err != nil {
		t.Fatalf("below threshold failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
		t.Fatalf("below threshold should skip: %+v", result)
	}

	globalRegion := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0})
	result, err = service.HandleRoomEvent(context.Background(), globalRegion)
	if err != nil {
		t.Fatalf("global region failed: %v", err)
	}
	if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" {
		t.Fatalf("global region should skip: %+v", result)
	}
}

func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) {
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
	repository.records["evt-1"] = broadcastdomain.OutboxRecord{
		AppCode:       "lalu",
		EventID:       "evt-1",
		GroupID:       "hy_lalu_bc_g",
		BroadcastType: broadcastdomain.TypeRedPacket,
		PayloadJSON:   `{"event_id":"evt-1","broadcast_type":"red_packet"}`,
		Status:        broadcastdomain.StatusPending,
	}

	result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts failed: %v", err)
	}
	if result.SuccessCount != 1 || len(publisher.messages) != 1 {
		t.Fatalf("publish result mismatch: result=%+v messages=%d", result, len(publisher.messages))
	}
	if repository.records["evt-1"].Status != broadcastdomain.StatusDelivered {
		t.Fatalf("record should be delivered: %+v", repository.records["evt-1"])
	}
	if publisher.messages[0].GroupID != "hy_lalu_bc_g" || publisher.messages[0].Desc != broadcastdomain.TypeRedPacket {
		t.Fatalf("message mismatch: %+v", publisher.messages[0])
	}
}

func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
	repository := newFakeRepository()
	service := New(Config{NodeID: "node-a"}, repository, nil, nil)
	service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
		UserID:     10001,
		Account:    "10001",
		Nickname:   "Tom",
		Avatar:     "https://example.com/avatar.png",
		RegionCode: "AR",
	}})
	event := broadcastdomain.RedPacketWalletEvent{
		AppCode:         "lalu",
		EventID:         "wallet-red-packet-created-1",
		EventType:       "WalletRedPacketCreated",
		PacketID:        "VRRP2026052712340001",
		PacketType:      "delayed",
		RoomID:          "9001",
		RegionID:        310,
		SenderUserID:    10001,
		TotalAmount:     15000,
		TotalCount:      5,
		RemainingAmount: 15000,
		RemainingCount:  5,
		Status:          "waiting_open",
		OpenAtMS:        1_780_000_000_000,
		ExpiresAtMS:     1_780_086_400_000,
		CreatedAtMS:     1_779_999_700_000,
	}

	if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
		t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
	}

	record := repository.records[event.EventID]
	data := redPacketPayloadData(t, record.PayloadJSON)
	if data["packetNo"] != event.PacketID || data["packetId"] != event.PacketID || data["scope"] != "ROOM" {
		t.Fatalf("packet identity mismatch: %+v", data)
	}
	if data["senderUserId"] != "10001" || data["actualAccount"] != "10001" || data["userNickname"] != "Tom" || data["userAvatar"] != "https://example.com/avatar.png" {
		t.Fatalf("sender display fields mismatch: %+v", data)
	}
	if data["totalAmount"] != float64(15000) || data["totalCount"] != float64(5) || data["remainAmount"] != float64(15000) || data["remainCount"] != float64(5) {
		t.Fatalf("red packet amount/count fields mismatch: %+v", data)
	}
	if data["packetMode"] != "DELAYED" || data["status"] != "WAITING_OPEN" || data["delaySeconds"] != float64(300) || data["regionCode"] != "AR" {
		t.Fatalf("red packet display state mismatch: %+v", data)
	}
}

func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
	nowMS := int64(1_779_999_700_000)
	openAtMS := int64(1_780_000_000_000)
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
	service.SetClock(func() time.Time { return time.UnixMilli(nowMS) })
	service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
		UserID:     10001,
		Account:    "10001",
		Nickname:   "Tom",
		Avatar:     "https://example.com/avatar.png",
		RegionCode: "AR",
	}})
	event := broadcastdomain.RedPacketWalletEvent{
		AppCode:         "lalu",
		EventID:         "wallet-red-packet-created-1",
		EventType:       "WalletRedPacketCreated",
		PacketID:        "VRRP2026052712340001",
		PacketType:      "delayed",
		RoomID:          "9001",
		RegionID:        310,
		SenderUserID:    10001,
		TotalAmount:     15000,
		TotalCount:      5,
		RemainingAmount: 15000,
		RemainingCount:  5,
		Status:          "waiting_open",
		OpenAtMS:        openAtMS,
		ExpiresAtMS:     1_780_086_400_000,
		CreatedAtMS:     nowMS,
	}

	if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
		t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
	}

	roomCreated := repository.records[event.EventID]
	regionCreated := repository.records[event.EventID+":region_created"]
	openDue := repository.records[event.EventID+":open_due"]
	if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
		t.Fatalf("room created outbox mismatch: %+v", roomCreated)
	}
	if regionCreated.GroupID != "hy_lalu_bc_r_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
		t.Fatalf("region created outbox mismatch: %+v", regionCreated)
	}
	if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
		t.Fatalf("open due outbox mismatch: %+v", openDue)
	}
	regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
	if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" {
		t.Fatalf("region created payload mismatch: %+v", regionData)
	}
	openDueData := redPacketPayloadData(t, openDue.PayloadJSON)
	if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) {
		t.Fatalf("open due payload mismatch: %+v", openDueData)
	}

	result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
	}
	if result.SuccessCount != 2 || len(publisher.messages) != 2 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
		t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
	}

	service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
	result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
	if err != nil {
		t.Fatalf("ProcessPendingBroadcasts after open failed: %v", err)
	}
	if result.SuccessCount != 1 || !containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
		t.Fatalf("after open should send OPEN_DUE: result=%+v messages=%+v", result, publisher.messages)
	}
}

func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)

	groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
	if err != nil {
		t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
	}
	if !removed || groupID != "hy_lalu_bc_r_1001" {
		t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed)
	}
	if publisher.deletedGroupID != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
		t.Fatalf("unexpected deleted member: group_id=%s user_id=%d", publisher.deletedGroupID, publisher.deletedUserID)
	}

	groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0)
	if err != nil {
		t.Fatalf("zero region should be a no-op, got: %v", err)
	}
	if removed || groupID != "" {
		t.Fatalf("zero region should not delete any group member: group_id=%s removed=%t", groupID, removed)
	}
}

func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
	repository := newFakeRepository()
	publisher := &fakePublisher{}
	service := New(Config{NodeID: "node-a", GroupIDPrefix: "test_"}, repository, publisher, nil)

	result, err := service.PublishRegionBroadcast(appcode.WithContext(context.Background(), "lalu"), PublishInput{
		EventID:       "evt-prefixed",
		BroadcastType: broadcastdomain.TypeSuperGift,
		RegionID:      1001,
		PayloadJSON:   `{"ok":true}`,
	})
	if err != nil {
		t.Fatalf("PublishRegionBroadcast failed: %v", err)
	}
	if result.GroupID != "test_hy_lalu_bc_r_1001" {
		t.Fatalf("prefixed publish group mismatch: %+v", result)
	}
	if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_1001" {
		t.Fatalf("prefixed outbox group mismatch: %+v", record)
	}

	groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
	if err != nil {
		t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
	}
	if !removed || groupID != "test_hy_lalu_bc_r_1001" || publisher.deletedGroupID != "test_hy_lalu_bc_r_1001" {
		t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%s", groupID, removed, publisher.deletedGroupID)
	}
}

func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(gift)
	if err != nil {
		t.Fatalf("marshal gift failed: %v", err)
	}
	return &roomeventsv1.EventEnvelope{
		EventId:      "evt-room-gift-1",
		RoomId:       "room-1001",
		EventType:    "RoomGiftSent",
		RoomVersion:  7,
		OccurredAtMs: 1_700_000_000_000,
		AppCode:      "lalu",
		Body:         body,
	}
}

func mustPasswordChangedEnvelope(t *testing.T, event *roomeventsv1.RoomPasswordChanged) *roomeventsv1.EventEnvelope {
	t.Helper()
	body, err := proto.Marshal(event)
	if err != nil {
		t.Fatalf("marshal password changed failed: %v", err)
	}
	return &roomeventsv1.EventEnvelope{
		EventId:      "evt-room-password-1",
		RoomId:       "room-1001",
		EventType:    "RoomPasswordChanged",
		RoomVersion:  8,
		OccurredAtMs: 1_700_000_001_000,
		AppCode:      "lalu",
		Body:         body,
	}
}

func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
	t.Helper()

	var payload struct {
		Type string         `json:"type"`
		Data map[string]any `json:"data"`
	}
	if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
		t.Fatalf("payload is not json: %v", err)
	}
	if payload.Type != "ROOM_RED_PACKET" || payload.Data == nil {
		t.Fatalf("unexpected red packet payload: %+v", payload)
	}
	return payload.Data
}

func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
	for _, message := range messages {
		if message.EventID == eventID {
			return true
		}
	}
	return false
}

type fakeRepository struct {
	records map[string]broadcastdomain.OutboxRecord
}

func newFakeRepository() *fakeRepository {
	return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}}
}

func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) {
	if existing, ok := r.records[record.EventID]; ok {
		return false, existing, nil
	}
	r.records[record.EventID] = record
	return true, record, nil
}

func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
	records := make([]broadcastdomain.OutboxRecord, 0, limit)
	for eventID, record := range r.records {
		if len(records) >= limit {
			break
		}
		if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable {
			continue
		}
		if record.NextRetryAtMS > nowMS {
			continue
		}
		record.Status = broadcastdomain.StatusDelivering
		record.LockedBy = workerID
		record.LockedUntilMS = lockUntilMS
		r.records[eventID] = record
		records = append(records, record)
	}
	return records, nil
}

func (r *fakeRepository) MarkBroadcastOutboxDelivered(_ context.Context, eventID string, nowMS int64) error {
	record, ok := r.records[eventID]
	if !ok {
		return errors.New("record not found")
	}
	record.Status = broadcastdomain.StatusDelivered
	record.UpdatedAtMS = nowMS
	r.records[eventID] = record
	return nil
}

func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID string, _ string, nowMS int64, nextRetryAtMS int64, _ int, lastError string) error {
	record, ok := r.records[eventID]
	if !ok {
		return errors.New("record not found")
	}
	record.Status = broadcastdomain.StatusRetryable
	record.AttemptCount++
	record.NextRetryAtMS = nextRetryAtMS
	record.LastError = lastError
	record.UpdatedAtMS = nowMS
	r.records[eventID] = record
	return nil
}

type fakePublisher struct {
	messages       []tencentim.CustomGroupMessage
	deletedGroupID string
	deletedUserID  int64
}

func (p *fakePublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error {
	return nil
}

func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
	p.messages = append(p.messages, message)
	return nil
}

func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error {
	p.deletedGroupID = groupID
	p.deletedUserID = userID
	return nil
}

type fakeSenderProfileSource struct {
	profile  SenderProfile
	profiles map[int64]SenderProfile
	err      error
}

func (s fakeSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (SenderProfile, error) {
	if s.profiles != nil {
		if profile, ok := s.profiles[userID]; ok {
			return profile, s.err
		}
	}
	profile := s.profile
	if profile.UserID <= 0 {
		profile.UserID = userID
	}
	return profile, s.err
}
