package service

import (
	"context"
	"testing"
	"time"

	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/pkg/appcode"
	"hyapp/services/room-service/internal/room/outbox"
)

type countingOutboxPublisher struct {
	events []*roomeventsv1.EventEnvelope
}

type scopedOutboxPublication struct {
	contextApp  string
	envelopeApp string
}

type scopedOutboxPublisher struct {
	published chan scopedOutboxPublication
}

type blockingRobotDisplayPublisher struct {
	started chan struct{}
	release chan struct{}
}

func newScopedOutboxPublisher() *scopedOutboxPublisher {
	return &scopedOutboxPublisher{published: make(chan scopedOutboxPublication, 2)}
}

func (p *scopedOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
	p.published <- scopedOutboxPublication{contextApp: appcode.FromContext(ctx), envelopeApp: envelope.GetAppCode()}
	return nil
}

func (p *blockingRobotDisplayPublisher) PublishOutboxEvent(ctx context.Context, _ *roomeventsv1.EventEnvelope) error {
	p.started <- struct{}{}
	select {
	case <-p.release:
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

type crossAppOutboxRepository struct {
	Repository
	claims []string
}

func (r *crossAppOutboxRepository) ListRoomOutboxAppCodes(context.Context) ([]string, error) {
	return []string{"fami", "lalu"}, nil
}

func (r *crossAppOutboxRepository) ClaimPendingOutbox(ctx context.Context, _ string, _ int, _ int64) ([]outbox.Record, error) {
	appCode := appcode.FromContext(ctx)
	r.claims = append(r.claims, appCode)
	if appCode != "fami" {
		return nil, nil
	}
	return []outbox.Record{{AppCode: "fami", EventID: "lucky_gift_drawn:fami-draw"}}, nil
}

func (p *countingOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
	p.events = append(p.events, envelope)
	return nil
}

func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) {
	if got := outboxSingleRecordLease(15 * time.Second); got != 45*time.Second {
		t.Fatalf("single-record lease = %s, want 45s", got)
	}
	if got := outboxSingleRecordLease(0); got != 9*time.Second {
		t.Fatalf("default single-record lease = %s, want 9s", got)
	}
}

func TestRTCMuteProjectionGateWaitHonorsContextAndReleasesRefs(t *testing.T) {
	svc := &Service{}
	key := rtcMuteProjectionKey{appCode: "lalu", roomID: "room-gate-timeout", userID: 1001}
	releaseOwner, err := svc.acquireRTCMuteProjection(context.Background(), key)
	if err != nil {
		t.Fatalf("acquire gate owner: %v", err)
	}

	waitCtx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	started := time.Now()
	if _, err := svc.acquireRTCMuteProjection(waitCtx, key); err == nil {
		t.Fatal("waiting gate acquisition must honor context timeout")
	}
	if elapsed := time.Since(started); elapsed > 250*time.Millisecond {
		t.Fatalf("gate waiter exceeded bounded context: %s", elapsed)
	}
	svc.rtcMuteGateMu.Lock()
	gate := svc.rtcMuteGates[key]
	if gate == nil || gate.refs != 1 {
		svc.rtcMuteGateMu.Unlock()
		t.Fatalf("cancelled waiter leaked gate ref: gate=%+v", gate)
	}
	svc.rtcMuteGateMu.Unlock()

	releaseOwner()
	svc.rtcMuteGateMu.Lock()
	remaining := len(svc.rtcMuteGates)
	svc.rtcMuteGateMu.Unlock()
	if remaining != 0 {
		t.Fatalf("last gate owner release must delete keyed gate, remaining=%d", remaining)
	}
}

func TestDirectAndRobotPublishersScopeBuiltRecordsFromContext(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), "fami")
	directPublisher := newScopedOutboxPublisher()
	robotPublisher := newScopedOutboxPublisher()
	svc := &Service{roomDisplayPublisher: directPublisher, robotDisplayPublisher: robotPublisher}
	build := func(eventType string) outbox.Record {
		record, err := outbox.Build("fami-room-publish", eventType, 3, time.UnixMilli(3000), &roomeventsv1.RoomUserJoined{UserId: 1001})
		if err != nil {
			t.Fatalf("build %s outbox: %v", eventType, err)
		}
		return record
	}

	svc.publishDirectIMRecordsBestEffort(ctx, []outbox.Record{build("RoomUserJoined")})
	svc.publishRobotDisplayRecordsBestEffort(ctx, []outbox.Record{build("RoomRobotUserJoined")})
	for name, publisher := range map[string]*scopedOutboxPublisher{"direct": directPublisher, "robot": robotPublisher} {
		select {
		case publication := <-publisher.published:
			if publication.contextApp != "fami" || publication.envelopeApp != "fami" {
				t.Fatalf("%s publisher lost scoped app: context=%q envelope=%q", name, publication.contextApp, publication.envelopeApp)
			}
		case <-time.After(time.Second):
			t.Fatalf("%s publisher did not receive built record", name)
		}
	}
}

func TestRobotDisplaySamplerUsesCommandAppForContextFreeRecords(t *testing.T) {
	svc := &Service{robotDisplaySampler: newRobotDisplaySampler()}
	build := func() outbox.Record {
		record, err := outbox.Build("shared-room-id", "RoomRobotLuckyGiftDrawn", 3, time.UnixMilli(3000), &roomeventsv1.RoomRobotLuckyGiftDrawn{})
		if err != nil {
			t.Fatalf("build robot display outbox: %v", err)
		}
		return record
	}
	// 同房间标识、事件类型和窗口在不同 App 下必须各保留一条；Build 尚未补 App，采样器只能从命令上下文取租户。
	if got := svc.sampleRobotDisplayRecords(appcode.WithContext(context.Background(), "fami"), []outbox.Record{build()}); len(got) != 1 {
		t.Fatalf("fami robot display unexpectedly sampled: %+v", got)
	}
	if got := svc.sampleRobotDisplayRecords(appcode.WithContext(context.Background(), "huwaa"), []outbox.Record{build()}); len(got) != 1 {
		t.Fatalf("huwaa robot display shared fami sample window: %+v", got)
	}
}

func TestRobotDisplayPublisherDropsWhenConcurrencySlotsAreFull(t *testing.T) {
	publisher := &blockingRobotDisplayPublisher{
		started: make(chan struct{}, robotDisplayMaxConcurrentPublishes+1),
		release: make(chan struct{}),
	}
	t.Cleanup(func() { close(publisher.release) })
	svc := &Service{nodeID: "robot-display-cap", robotDisplayPublisher: publisher}
	record, err := outbox.Build("robot-display-cap-room", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{})
	if err != nil {
		t.Fatalf("build capped robot display record: %v", err)
	}

	for index := 0; index < robotDisplayMaxConcurrentPublishes+1; index++ {
		svc.publishRobotDisplayRecordsBestEffort(context.Background(), []outbox.Record{record})
	}
	if got := len(svc.robotDisplayPublishSlots); got != robotDisplayMaxConcurrentPublishes {
		t.Fatalf("robot display active publishers = %d, want hard cap %d", got, robotDisplayMaxConcurrentPublishes)
	}
	deadline := time.NewTimer(time.Second)
	defer deadline.Stop()
	for started := 0; started < robotDisplayMaxConcurrentPublishes; started++ {
		select {
		case <-publisher.started:
		case <-deadline.C:
			t.Fatalf("only %d capped robot publishers started", started)
		}
	}
	select {
	case <-publisher.started:
		t.Fatal("robot display publisher exceeded its concurrency cap")
	default:
	}
}

func TestClaimPendingOutboxFindsNonDefaultApp(t *testing.T) {
	repository := &crossAppOutboxRepository{}
	svc := &Service{nodeID: "room-cross-app", repository: repository}
	records, err := svc.claimPendingOutbox(context.Background(), OutboxWorkerOptions{BatchSize: 1, PublishTimeout: time.Second})
	if err != nil {
		t.Fatalf("claim cross-app outbox: %v", err)
	}
	if len(records) != 1 || records[0].AppCode != "fami" {
		t.Fatalf("non-default app outbox was not claimed: %+v", records)
	}
	if len(repository.claims) == 0 || repository.claims[len(repository.claims)-1] != "fami" {
		t.Fatalf("claim contexts did not reach fami: %v", repository.claims)
	}
}

func TestSortOutboxRecordsPrioritizesGiftDisplay(t *testing.T) {
	// Direct IM/robot display 仍使用批内排序；低优先级事件故意更早，验证礼物展示优先。
	records := []outbox.Record{
		{EventID: "rank", EventType: "RoomRankChanged", CreatedAtMS: 1},
		{EventID: "gift", EventType: "RoomGiftSent", CreatedAtMS: 2},
		{EventID: "other", EventType: "RoomNoticeCreated", CreatedAtMS: 0},
		{EventID: "heat", EventType: "RoomHeatChanged", CreatedAtMS: 0},
	}
	sortOutboxRecords(records)

	got := []string{records[0].EventID, records[1].EventID, records[2].EventID, records[3].EventID}
	want := []string{"gift", "other", "heat", "rank"}
	for index := range want {
		if got[index] != want[index] {
			t.Fatalf("priority order mismatch got=%v want=%v", got, want)
		}
	}
}

func TestPublishOutboxRecordRoutesLuckyGiftDisplayOnlyToRoomIM(t *testing.T) {
	display := &countingOutboxPublisher{}
	fanout := &countingOutboxPublisher{}
	svc := &Service{roomDisplayPublisher: display, outboxPublisher: fanout}
	record := outbox.Record{
		EventID:   "lucky_gift_drawn:draw-1",
		EventType: roomLuckyGiftDrawnEventType,
		RoomID:    "room-1",
		Envelope: &roomeventsv1.EventEnvelope{
			EventId:   "lucky_gift_drawn:draw-1",
			EventType: roomLuckyGiftDrawnEventType,
			RoomId:    "room-1",
		},
	}

	if err := svc.publishOutboxRecord(context.Background(), record); err != nil {
		t.Fatalf("publish lucky display outbox failed: %v", err)
	}
	if len(display.events) != 1 {
		t.Fatalf("lucky display must reach room IM publisher once, got %d", len(display.events))
	}
	if len(fanout.events) != 0 {
		t.Fatalf("derived lucky display must not return to room MQ fanout: %d", len(fanout.events))
	}
}

func TestPublishOutboxRecordKeepsLuckyGiftPendingWithoutRoomIMPublisher(t *testing.T) {
	svc := &Service{outboxPublisher: &countingOutboxPublisher{}}
	err := svc.publishOutboxRecord(context.Background(), outbox.Record{
		EventID:   "lucky_gift_drawn:draw-2",
		EventType: roomLuckyGiftDrawnEventType,
		RoomID:    "room-2",
		Envelope:  &roomeventsv1.EventEnvelope{EventId: "lucky_gift_drawn:draw-2", EventType: roomLuckyGiftDrawnEventType, RoomId: "room-2"},
	})
	if err == nil {
		t.Fatal("lucky display without room IM publisher must stay retryable")
	}
}
