package mysql

import (
	"bytes"
	"context"
	"errors"
	"sync"
	"testing"
	"time"

	"google.golang.org/protobuf/proto"
	roomeventsv1 "hyapp.local/api/proto/events/room/v1"
	"hyapp/internal/testutil/mysqlschema"
	"hyapp/pkg/appcode"
	"hyapp/services/room-service/internal/room/outbox"
	roomservice "hyapp/services/room-service/internal/room/service"
)

func TestSaveMutationScopesBuiltOutboxToContextApp(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), "fami")
	repo := newCommandLogTestRepository(t, ctx)
	record, err := outbox.Build("fami-room-outbox", "RoomCreated", 1, time.UnixMilli(1000), &roomeventsv1.RoomCreated{})
	if err != nil {
		t.Fatalf("build room outbox: %v", err)
	}
	if record.AppCode != "" || record.Envelope.GetAppCode() != "" {
		t.Fatalf("context-free Build must not guess an app: record=%q envelope=%q", record.AppCode, record.Envelope.GetAppCode())
	}

	if err := repo.SaveMutation(ctx, roomservice.MutationCommit{
		Command: roomservice.CommandRecord{
			RoomID:      record.RoomID,
			RoomVersion: 1,
			CommandID:   "cmd-fami-room-outbox",
			CommandType: "create_room",
			Payload:     []byte(`{"command_id":"cmd-fami-room-outbox"}`),
			Replayable:  true,
			OwnerNodeID: "node-fami",
			LeaseToken:  "lease-fami",
			CreatedAtMS: record.CreatedAtMS,
		},
		OutboxRecords: []outbox.Record{record},
	}); err != nil {
		t.Fatalf("save fami mutation: %v", err)
	}

	var storedApp string
	var envelopeBytes []byte
	if err := repo.db.QueryRowContext(ctx,
		`SELECT app_code, envelope FROM room_outbox WHERE app_code = ? AND event_id = ?`,
		"fami", record.EventID,
	).Scan(&storedApp, &envelopeBytes); err != nil {
		t.Fatalf("read persisted fami outbox: %v", err)
	}
	var envelope roomeventsv1.EventEnvelope
	if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
		t.Fatalf("decode persisted fami envelope: %v", err)
	}
	if storedApp != "fami" || envelope.GetAppCode() != "fami" {
		t.Fatalf("persistence boundary lost scoped app: row=%q envelope=%q", storedApp, envelope.GetAppCode())
	}
}

func TestSaveMutationRejectsSameCommandInsteadOfContinuingSideEffects(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	repo := newCommandLogTestRepository(t, ctx)
	roomID := "room-command-id-race"
	insertCommandLogTestRoom(t, ctx, repo, roomID)
	first := roomservice.MutationCommit{Command: roomservice.CommandRecord{
		AppCode: appcode.Default, RoomID: roomID, RoomVersion: 1, CommandID: "cmd-stable",
		CommandType: "send_gift", Payload: []byte(`{"command_id":"cmd-stable"}`), ResultPayload: []byte("first-result"),
		Replayable: true, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 1000,
	}}
	if err := repo.SaveMutation(ctx, first); err != nil {
		t.Fatalf("save first command: %v", err)
	}
	second := first
	second.Command.RoomVersion = 2
	second.Command.ResultPayload = []byte("must-not-overwrite")
	if err := repo.SaveMutation(ctx, second); !errors.Is(err, roomservice.ErrCommandAlreadyCommitted) {
		t.Fatalf("same command_id should return replay signal, got %v", err)
	}
	record, exists, err := repo.GetCommand(ctx, roomID, "cmd-stable")
	if err != nil || !exists {
		t.Fatalf("read first command: exists=%v err=%v", exists, err)
	}
	if record.RoomVersion != 1 || !bytes.Equal(record.ResultPayload, []byte("first-result")) {
		t.Fatalf("duplicate command changed original result: %+v", record)
	}
}

func TestSaveSnapshotDoesNotOverwriteSameVersion(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	repo := newCommandLogTestRepository(t, ctx)

	goodPayload := []byte("snapshot-good-v10")
	if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
		AppCode:     appcode.Default,
		RoomID:      "room-snapshot-version",
		RoomVersion: 10,
		Payload:     goodPayload,
		CreatedAtMS: 1000,
	}); err != nil {
		t.Fatalf("save initial snapshot: %v", err)
	}
	if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
		AppCode:     appcode.Default,
		RoomID:      "room-snapshot-version",
		RoomVersion: 10,
		Payload:     []byte("snapshot-bad-same-version"),
		CreatedAtMS: 2000,
	}); err != nil {
		t.Fatalf("save same-version snapshot: %v", err)
	}
	got, exists, err := repo.GetLatestSnapshot(ctx, "room-snapshot-version")
	if err != nil {
		t.Fatalf("get same-version snapshot: %v", err)
	}
	if !exists || got.RoomVersion != 10 || !bytes.Equal(got.Payload, goodPayload) || got.CreatedAtMS != 1000 {
		t.Fatalf("same-version snapshot overwrote recovery source: got version=%d created=%d payload=%q", got.RoomVersion, got.CreatedAtMS, got.Payload)
	}

	if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{
		AppCode:     appcode.Default,
		RoomID:      "room-snapshot-version",
		RoomVersion: 11,
		Payload:     []byte("snapshot-new-v11"),
		CreatedAtMS: 3000,
	}); err != nil {
		t.Fatalf("save newer snapshot: %v", err)
	}
	got, exists, err = repo.GetLatestSnapshot(ctx, "room-snapshot-version")
	if err != nil {
		t.Fatalf("get newer snapshot: %v", err)
	}
	if !exists || got.RoomVersion != 11 || !bytes.Equal(got.Payload, []byte("snapshot-new-v11")) {
		t.Fatalf("newer snapshot should replace old one: got version=%d payload=%q", got.RoomVersion, got.Payload)
	}
}

func TestSaveMutationRejectsDuplicateReplayableRoomVersion(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	repo := newCommandLogTestRepository(t, ctx)
	roomID := "room-command-version"

	if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
		AppCode:     appcode.Default,
		RoomID:      roomID,
		RoomVersion: 20,
		CommandID:   "cmd-first",
		CommandType: "join_room",
		Payload:     []byte(`{"command_id":"cmd-first"}`),
		Replayable:  true,
		OwnerNodeID: "node-a",
		LeaseToken:  "lease-a",
		CreatedAtMS: 1000,
	}}); err != nil {
		t.Fatalf("save first replayable command: %v", err)
	}
	if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
		AppCode:     appcode.Default,
		RoomID:      roomID,
		RoomVersion: 20,
		CommandID:   "cmd-second",
		CommandType: "leave_room",
		Payload:     []byte(`{"command_id":"cmd-second"}`),
		Replayable:  true,
		OwnerNodeID: "node-b",
		LeaseToken:  "lease-b",
		CreatedAtMS: 2000,
	}}); err == nil {
		t.Fatal("duplicate replayable command version should be rejected")
	}
	if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
		AppCode:     appcode.Default,
		RoomID:      roomID,
		RoomVersion: 20,
		CommandID:   "cmd-noop",
		CommandType: "join_room",
		Payload:     []byte(`{"command_id":"cmd-noop"}`),
		Replayable:  false,
		OwnerNodeID: "node-a",
		LeaseToken:  "lease-a",
		CreatedAtMS: 3000,
	}}); err != nil {
		t.Fatalf("same-version non-replayable idempotency record should still be saved: %v", err)
	}
}

func TestSaveMutationAllowsDuplicateReplayableGiftVersion(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	repo := newCommandLogTestRepository(t, ctx)
	roomID := "room-gift-version"

	for _, commandID := range []string{"cmd-gift-first", "cmd-gift-second"} {
		if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
			AppCode:     appcode.Default,
			RoomID:      roomID,
			RoomVersion: 40,
			CommandID:   commandID,
			CommandType: "send_gift",
			Payload:     []byte(`{"command_id":"` + commandID + `"}`),
			Replayable:  true,
			OwnerNodeID: "node-a",
			LeaseToken:  "lease-a",
			CreatedAtMS: 5000,
		}}); err != nil {
			t.Fatalf("same-version send_gift should remain appendable for additive recovery facts: %v", err)
		}
	}
}

func TestSaveMutationSerializesConcurrentReplayableRoomVersion(t *testing.T) {
	ctx := appcode.WithContext(context.Background(), appcode.Default)
	repo := newCommandLogTestRepository(t, ctx)
	roomID := "room-command-version-race"
	insertCommandLogTestRoom(t, ctx, repo, roomID)

	start := make(chan struct{})
	errs := make(chan error, 2)
	var wg sync.WaitGroup
	for _, commandID := range []string{"cmd-race-a", "cmd-race-b"} {
		wg.Add(1)
		go func(commandID string) {
			defer wg.Done()
			<-start
			errs <- repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{
				AppCode:     appcode.Default,
				RoomID:      roomID,
				RoomVersion: 30,
				CommandID:   commandID,
				CommandType: "room_heartbeat",
				Payload:     []byte(`{"command_id":"` + commandID + `"}`),
				Replayable:  true,
				OwnerNodeID: "node-a",
				LeaseToken:  "lease-a",
				CreatedAtMS: 4000,
			}})
		}(commandID)
	}
	close(start)
	wg.Wait()
	close(errs)

	successes := 0
	failures := 0
	for err := range errs {
		if err == nil {
			successes++
			continue
		}
		failures++
	}
	if successes != 1 || failures != 1 {
		t.Fatalf("concurrent same-version replayable writes should produce one success and one conflict, got successes=%d failures=%d", successes, failures)
	}
}

func newCommandLogTestRepository(t *testing.T, ctx context.Context) *Repository {
	t.Helper()

	schema := mysqlschema.New(t, mysqlschema.Config{
		EnvVar:         "ROOM_SERVICE_MYSQL_TEST_DSN",
		InitDBPath:     mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_room_service.sql"),
		DatabasePrefix: "hyapp_room_command_test",
	})
	repo, err := Open(ctx, schema.DSN, 4, 4)
	if err != nil {
		t.Fatalf("open repository: %v", err)
	}
	t.Cleanup(func() {
		_ = repo.Close()
	})
	return repo
}

func insertCommandLogTestRoom(t *testing.T, ctx context.Context, repo *Repository, roomID string) {
	t.Helper()

	if _, err := repo.db.ExecContext(ctx,
		`INSERT INTO rooms (
		   app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, visible_region_id, created_at_ms, updated_at_ms
		 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		appcode.Default,
		roomID,
		roomID+"-short",
		1001,
		8,
		"voice",
		"active",
		10,
		1000,
		1000,
	); err != nil {
		t.Fatalf("insert command log test room: %v", err)
	}
}
