package mysql

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

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"

	filemanager "wucher/internal/domain/file_manager"
	"wucher/internal/queue"
	"wucher/internal/shared/pkg/uuidv7"
)

func openFileManagerFileRepoTestDB(t *testing.T) *gorm.DB {
	t.Helper()
	dsn := fmt.Sprintf("file:file_manager_file_repo_%d?mode=memory&cache=shared", time.Now().UnixNano())
	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
		NowFunc: func() time.Time { return time.Now().UTC() },
	})
	if err != nil {
		t.Fatalf("open sqlite: %v", err)
	}
	if err := db.AutoMigrate(
		&filemanager.Folder{},
		&filemanager.File{},
		&filemanager.FileUploadIntent{},
		&filemanager.FileProcessingOutboxMessage{},
		&filemanager.FileStatusRealtimeEvent{},
	); err != nil {
		t.Fatalf("auto migrate: %v", err)
	}
	return db
}

func newFileProcessingOutboxRecordForRepoTest(t *testing.T, fileID []byte, bucket, objectKey string, sizeBytes int64) *filemanager.FileProcessingOutboxMessage {
	t.Helper()
	fileUUID, err := uuidv7.BytesToString(fileID)
	if err != nil {
		t.Fatalf("file uuid: %v", err)
	}
	serializer := queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, "")
	message, err := serializer.Serialize(context.Background(), queue.FileProcessingJob{
		FileUUID:  fileUUID,
		Bucket:    bucket,
		ObjectKey: objectKey,
		SizeBytes: sizeBytes,
	})
	if err != nil {
		t.Fatalf("serialize outbox message: %v", err)
	}
	record, err := filemanager.NewFileProcessingOutboxMessage(time.Now().UTC(), &filemanager.FileProcessingOutboundMessage{
		ID:              message.ID,
		Body:            append([]byte(nil), message.Body...),
		Attributes:      message.Attributes,
		MessageGroupID:  message.MessageGroupID,
		DeduplicationID: message.DeduplicationID,
	})
	if err == nil {
		return record
	}
	t.Fatalf("new outbox message: %v", err)
	return nil
}

func seedFolderForFileRepo(t *testing.T, db *gorm.DB, name string) *filemanager.Folder {
	t.Helper()
	row := &filemanager.Folder{Name: name, NameNormalized: name, NameSlot: "live"}
	if err := db.WithContext(context.Background()).Create(row).Error; err != nil {
		t.Fatalf("seed folder: %v", err)
	}
	return row
}

func TestNewFileManagerFileRepository(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	if repo == nil || repo.db == nil {
		t.Fatalf("expected repository initialized")
	}
}

func TestFileManagerFileRepositoryCreateFileStatusRealtimeEvent(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)

	fileID := uuidv7.MustBytes()
	userID := uuidv7.MustBytes()
	occurredAt := time.Now().UTC()
	err := repo.CreateFileStatusRealtimeEvent(context.Background(), filemanager.FileStatusRealtimeEventInput{
		FileID:     fileID,
		UserID:     userID,
		Status:     filemanager.FileStatusUploaded,
		Name:       "report.pdf",
		OccurredAt: occurredAt,
	})
	if err != nil {
		t.Fatalf("create realtime event: %v", err)
	}

	var rows []filemanager.FileStatusRealtimeEvent
	if err := db.WithContext(context.Background()).
		Where("file_id = ? AND user_id = ? AND status = ?", fileID, userID, filemanager.FileStatusUploaded).
		Find(&rows).Error; err != nil {
		t.Fatalf("query realtime events: %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("expected one realtime event row, got %d", len(rows))
	}
	if rows[0].DeliveredAt != nil {
		t.Fatalf("expected new realtime event as undelivered")
	}
}

func TestFileManagerFileRepositoryUploadIntentLifecycle(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)

	intent := &filemanager.FileUploadIntent{
		ID:             uuidv7.MustBytes(),
		Name:           "report.pdf",
		NameNormalized: "report.pdf",
		Extension:      "pdf",
		SizeBytes:      1024,
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		Status:         filemanager.FileUploadIntentStatusPending,
		ExpiresAt:      time.Now().UTC().Add(10 * time.Minute),
		CreatedBy:      uuidv7.MustBytes(),
		UpdatedBy:      uuidv7.MustBytes(),
	}
	if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
		t.Fatalf("create upload intent: %v", err)
	}

	found, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
	if err != nil {
		t.Fatalf("get upload intent: %v", err)
	}
	if found == nil {
		t.Fatalf("expected upload intent")
	}
	if found.ObjectKey != intent.ObjectKey {
		t.Fatalf("unexpected object key, got %q want %q", found.ObjectKey, intent.ObjectKey)
	}

	locked, err := repo.GetUploadIntentByIDForUpdate(context.Background(), intent.ID)
	if err != nil {
		t.Fatalf("get upload intent for update: %v", err)
	}
	if locked == nil {
		t.Fatalf("expected locked upload intent")
	}

	completedAt := time.Now().UTC()
	if err := repo.MarkUploadIntentCompleted(context.Background(), filemanager.FileUploadIntentCompleteParams{
		ID:          intent.ID,
		UpdatedBy:   intent.UpdatedBy,
		CompletedAt: completedAt,
	}); err != nil {
		t.Fatalf("mark upload intent completed: %v", err)
	}

	done, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
	if err != nil {
		t.Fatalf("get completed upload intent: %v", err)
	}
	if done == nil {
		t.Fatalf("expected completed upload intent")
	}
	if filemanager.NormalizeFileUploadIntentStatus(done.Status) != filemanager.FileUploadIntentStatusCompleted {
		t.Fatalf("expected completed status, got %q", done.Status)
	}
	if done.CompletedAt == nil {
		t.Fatalf("expected completed_at set")
	}
}

func TestFileManagerFileRepositoryMarkUploadIntentExpired(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)

	intent := &filemanager.FileUploadIntent{
		ID:             uuidv7.MustBytes(),
		Name:           "expired.pdf",
		NameNormalized: "expired.pdf",
		Extension:      "pdf",
		SizeBytes:      12,
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		Status:         filemanager.FileUploadIntentStatusPending,
		ExpiresAt:      time.Now().UTC().Add(-time.Minute),
	}
	if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
		t.Fatalf("create upload intent: %v", err)
	}

	if err := repo.MarkUploadIntentExpired(context.Background(), filemanager.FileUploadIntentExpireParams{
		ID:        intent.ID,
		UpdatedBy: uuidv7.MustBytes(),
		ExpiredAt: time.Now().UTC(),
	}); err != nil {
		t.Fatalf("mark upload intent expired: %v", err)
	}

	expired, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
	if err != nil {
		t.Fatalf("get expired upload intent: %v", err)
	}
	if expired == nil {
		t.Fatalf("expected expired upload intent")
	}
	if filemanager.NormalizeFileUploadIntentStatus(expired.Status) != filemanager.FileUploadIntentStatusExpired {
		t.Fatalf("expected expired status, got %q", expired.Status)
	}
}

func TestFileManagerFileRepositoryUpdateUploadIntentSize(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)

	intent := &filemanager.FileUploadIntent{
		ID:             uuidv7.MustBytes(),
		Name:           "draft.xlsx",
		NameNormalized: "draft.xlsx",
		Extension:      "xlsx",
		SizeBytes:      100,
		MimeType:       "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		Status:         filemanager.FileUploadIntentStatusPending,
		ExpiresAt:      time.Now().UTC().Add(10 * time.Minute),
	}
	if err := repo.CreateUploadIntent(context.Background(), intent); err != nil {
		t.Fatalf("create upload intent: %v", err)
	}

	if err := repo.UpdateUploadIntentSize(context.Background(), filemanager.FileUploadIntentSizeUpdateParams{
		ID:        intent.ID,
		SizeBytes: 64,
		UpdatedBy: uuidv7.MustBytes(),
	}); err != nil {
		t.Fatalf("update upload intent size: %v", err)
	}

	updated, err := repo.GetUploadIntentByID(context.Background(), intent.ID)
	if err != nil {
		t.Fatalf("get updated upload intent: %v", err)
	}
	if updated == nil {
		t.Fatalf("expected upload intent")
	}
	if updated.SizeBytes != 64 {
		t.Fatalf("expected size_bytes 64, got %d", updated.SizeBytes)
	}
}

func TestFileManagerFileRepositoryWithTransaction_PersistFileAndOutbox(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		ID:             uuidv7.MustBytes(),
		FolderID:       folder.ID,
		Name:           "upload.pdf",
		NameNormalized: "upload.pdf",
		Extension:      "pdf",
		SizeBytes:      1024,
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusUploaded,
	}
	outbox := newFileProcessingOutboxRecordForRepoTest(t, row.ID, row.Bucket, row.ObjectKey, row.SizeBytes)

	err := repo.WithTransaction(context.Background(), func(fileRepo filemanager.FileRepository, outboxWriter filemanager.FileProcessingOutboxWriter) error {
		if err := fileRepo.Create(context.Background(), row); err != nil {
			return err
		}
		return outboxWriter.CreateFileProcessingOutboxMessage(context.Background(), outbox)
	})
	if err != nil {
		t.Fatalf("with transaction: %v", err)
	}

	got, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if got == nil {
		t.Fatalf("expected uploaded file persisted")
	}

	var total int64
	if err := db.Model(&filemanager.FileProcessingOutboxMessage{}).Where("message_id = ?", outbox.MessageID).Count(&total).Error; err != nil {
		t.Fatalf("count outbox: %v", err)
	}
	if total != 1 {
		t.Fatalf("expected one outbox row, got %d", total)
	}
}

func TestFileManagerFileRepositoryWithTransaction_Rollback(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		ID:             uuidv7.MustBytes(),
		FolderID:       folder.ID,
		Name:           "upload.pdf",
		NameNormalized: "upload.pdf",
		Extension:      "pdf",
		SizeBytes:      1024,
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusUploaded,
	}
	outbox := newFileProcessingOutboxRecordForRepoTest(t, row.ID, row.Bucket, row.ObjectKey, row.SizeBytes)
	rollbackErr := errors.New("force rollback")

	err := repo.WithTransaction(context.Background(), func(fileRepo filemanager.FileRepository, outboxWriter filemanager.FileProcessingOutboxWriter) error {
		if err := fileRepo.Create(context.Background(), row); err != nil {
			return err
		}
		if err := outboxWriter.CreateFileProcessingOutboxMessage(context.Background(), outbox); err != nil {
			return err
		}
		return rollbackErr
	})
	if !errors.Is(err, rollbackErr) {
		t.Fatalf("expected rollback error, got %v", err)
	}

	got, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if got != nil {
		t.Fatalf("expected file insert rolled back")
	}

	var total int64
	if err := db.Model(&filemanager.FileProcessingOutboxMessage{}).Where("message_id = ?", outbox.MessageID).Count(&total).Error; err != nil {
		t.Fatalf("count outbox: %v", err)
	}
	if total != 0 {
		t.Fatalf("expected outbox insert rolled back, got %d", total)
	}
}

func TestFileManagerFileRepositoryCreate_AllowsMultipleNullThumbnailObjectKeys(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "images")

	first := &filemanager.File{
		ID:             uuidv7.MustBytes(),
		FolderID:       folder.ID,
		Name:           "one.jpg",
		NameNormalized: "one.jpg",
		Extension:      "jpg",
		SizeBytes:      10,
		MimeType:       "image/jpeg",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d-one", time.Now().UnixNano()),
		NameSlot:       "tmp-one",
		Status:         filemanager.FileStatusUploaded,
	}
	second := &filemanager.File{
		ID:             uuidv7.MustBytes(),
		FolderID:       folder.ID,
		Name:           "two.jpg",
		NameNormalized: "two.jpg",
		Extension:      "jpg",
		SizeBytes:      20,
		MimeType:       "image/jpeg",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d-two", time.Now().UnixNano()),
		NameSlot:       "tmp-two",
		Status:         filemanager.FileStatusUploaded,
	}

	if err := repo.Create(context.Background(), first); err != nil {
		t.Fatalf("create first file: %v", err)
	}
	if err := repo.Create(context.Background(), second); err != nil {
		t.Fatalf("create second file with nil thumbnail object key: %v", err)
	}
}

func TestFileManagerFileRepositoryCreateAndGetters(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")
	templateUUID := uuidv7.MustBytes()
	takeoverID := uuidv7.MustBytes()

	objectKey := fmt.Sprintf("fm/objects/%d", time.Now().UnixNano())
	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "Policy.pdf",
		NameNormalized: "policy.pdf",
		Extension:      "pdf",
		SizeBytes:      1024,
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      objectKey,
		NameSlot:       "live",
		Status:         filemanager.FileStatusReady,
		TemplateUUID:   templateUUID,
		TakeoverID:     takeoverID,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("create file: %v", err)
	}
	if len(row.ID) != 16 {
		t.Fatalf("expected generated id")
	}

	byID, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if byID == nil || byID.Name != "Policy.pdf" {
		t.Fatalf("expected by id result")
	}

	byKey, err := repo.GetByObjectKey(context.Background(), objectKey)
	if err != nil {
		t.Fatalf("get by object key: %v", err)
	}
	if byKey == nil || !bytes.Equal(byKey.ID, row.ID) {
		t.Fatalf("expected by object key result")
	}

	byName, err := repo.GetByFolderAndName(context.Background(), folder.ID, "policy.pdf")
	if err != nil {
		t.Fatalf("get by folder/name: %v", err)
	}
	if byName == nil || !bytes.Equal(byName.ID, row.ID) {
		t.Fatalf("expected by folder/name result")
	}

	byTemplateTakeover, err := repo.GetByTemplateAndTakeover(context.Background(), templateUUID, takeoverID)
	if err != nil {
		t.Fatalf("get by template/takeover: %v", err)
	}
	if byTemplateTakeover == nil || !bytes.Equal(byTemplateTakeover.ID, row.ID) {
		t.Fatalf("expected by template/takeover result")
	}

	blankKey, err := repo.GetByObjectKey(context.Background(), "   ")
	if err != nil {
		t.Fatalf("get blank object key: %v", err)
	}
	if blankKey != nil {
		t.Fatalf("expected nil for blank object key")
	}
}

func TestFileManagerFileRepositoryGetByTemplateAndTakeoverReturnsDraftRows(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")
	templateUUID := uuidv7.MustBytes()
	takeoverID := uuidv7.MustBytes()

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "Draft.docx",
		NameNormalized: "draft.docx",
		Extension:      "docx",
		SizeBytes:      2048,
		MimeType:       "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d-draft", time.Now().UnixNano()),
		NameSlot:       "draft",
		Status:         filemanager.FileStatusReady,
		TemplateUUID:   templateUUID,
		TakeoverID:     takeoverID,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("create draft file: %v", err)
	}

	got, err := repo.GetByTemplateAndTakeover(context.Background(), templateUUID, takeoverID)
	if err != nil {
		t.Fatalf("get by template/takeover: %v", err)
	}
	if got == nil || !bytes.Equal(got.ID, row.ID) {
		t.Fatalf("expected draft row to be returned, got %#v", got)
	}
}

func TestFileManagerFileRepositoryUpdate(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "Old.txt",
		NameNormalized: "old.txt",
		MimeType:       "text/plain",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "live",
		Status:         filemanager.FileStatusReady,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	row.Name = "New.txt"
	row.NameNormalized = "new.txt"
	row.SizeBytes = 88
	if err := repo.Update(context.Background(), row); err != nil {
		t.Fatalf("update file: %v", err)
	}

	got, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get after update: %v", err)
	}
	if got == nil || got.Name != "New.txt" || got.SizeBytes != 88 {
		t.Fatalf("unexpected updated row: %+v", got)
	}
}

func TestFileManagerFileRepositoryTransitionStatus(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "upload.pdf",
		NameNormalized: "upload.pdf",
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusUploaded,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	startedAt := time.Now().UTC().Truncate(time.Second)
	if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
		ID:                  row.ID,
		ExpectedFrom:        filemanager.FileStatusUploaded,
		To:                  filemanager.FileStatusProcessing,
		ProcessingStartedAt: &startedAt,
	}); err != nil {
		t.Fatalf("to processing: %v", err)
	}

	mid, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get after processing: %v", err)
	}
	if mid == nil || mid.Status != filemanager.FileStatusProcessing {
		t.Fatalf("expected processing status")
	}
	if mid.ProcessingStartedAt == nil || !mid.ProcessingStartedAt.Equal(startedAt) {
		t.Fatalf("expected processing_started_at set")
	}

	processedAt := time.Now().UTC().Truncate(time.Second)
	validatedAt := processedAt.Add(2 * time.Second)
	if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
		ID:           row.ID,
		ExpectedFrom: filemanager.FileStatusProcessing,
		To:           filemanager.FileStatusValidated,
		ProcessedAt:  &processedAt,
		ValidatedAt:  &validatedAt,
	}); err != nil {
		t.Fatalf("to validated: %v", err)
	}

	if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
		ID:           row.ID,
		ExpectedFrom: filemanager.FileStatusUploaded,
		To:           filemanager.FileStatusReady,
	}); !errors.Is(err, filemanager.ErrInvalidFileStatusTransition) {
		t.Fatalf("expected invalid transition for uploaded->ready via transition api, got %v", err)
	}
}

func TestFileManagerFileRepositoryTransitionStatus_FailedAndMismatch(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "processing.pdf",
		NameNormalized: "processing.pdf",
		MimeType:       "application/pdf",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusProcessing,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	reason := "invalid payload"
	if err := repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
		ID:            row.ID,
		ExpectedFrom:  filemanager.FileStatusProcessing,
		To:            filemanager.FileStatusFailed,
		FailureReason: &reason,
	}); err != nil {
		t.Fatalf("to failed: %v", err)
	}

	got, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get after failed: %v", err)
	}
	if got == nil || got.Status != filemanager.FileStatusFailed {
		t.Fatalf("expected failed status")
	}
	if got.FailedAt == nil {
		t.Fatalf("expected failed_at set")
	}
	if got.FailureReason != reason {
		t.Fatalf("expected failure reason %q, got %q", reason, got.FailureReason)
	}

	err = repo.TransitionStatus(context.Background(), filemanager.FileStatusTransitionParams{
		ID:           row.ID,
		ExpectedFrom: filemanager.FileStatusProcessing,
		To:           filemanager.FileStatusValidated,
	})
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		t.Fatalf("expected gorm.ErrRecordNotFound for status mismatch, got %v", err)
	}
}

func TestFileManagerFileRepositoryFinalize(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	rootFolder := seedFolderForFileRepo(t, db, "root")
	targetFolder := seedFolderForFileRepo(t, db, "target")

	row := &filemanager.File{
		FolderID:       rootFolder.ID,
		Name:           "upload.tmp",
		NameNormalized: "upload.tmp",
		Extension:      "tmp",
		MimeType:       "application/octet-stream",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusValidated,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	if err := repo.Finalize(context.Background(), filemanager.FileFinalizeParams{
		ID:             row.ID,
		ExpectedFrom:   filemanager.FileStatusValidated,
		FolderID:       targetFolder.ID,
		Name:           "Final.pdf",
		NameNormalized: "final.pdf",
		Extension:      "pdf",
		UpdatedBy:      uuidv7.MustBytes(),
	}); err != nil {
		t.Fatalf("finalize file: %v", err)
	}

	got, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get after finalize: %v", err)
	}
	if got == nil {
		t.Fatalf("expected finalized row")
	}
	if got.Status != filemanager.FileStatusReady {
		t.Fatalf("expected status ready, got %s", got.Status)
	}
	if got.NameSlot != "live" {
		t.Fatalf("expected name slot live, got %s", got.NameSlot)
	}
	if !bytes.Equal(got.FolderID, targetFolder.ID) {
		t.Fatalf("expected folder updated")
	}
	if got.Name != "Final.pdf" || got.NameNormalized != "final.pdf" || got.Extension != "pdf" {
		t.Fatalf("expected naming fields updated")
	}
}

func TestFileManagerFileRepositoryFinalize_StatusMismatch(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "ready.txt",
		NameNormalized: "ready.txt",
		MimeType:       "text/plain",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "live",
		Status:         filemanager.FileStatusReady,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	err := repo.Finalize(context.Background(), filemanager.FileFinalizeParams{
		ID:             row.ID,
		ExpectedFrom:   filemanager.FileStatusValidated,
		FolderID:       folder.ID,
		Name:           "ready.txt",
		NameNormalized: "ready.txt",
		Extension:      "txt",
		UpdatedBy:      uuidv7.MustBytes(),
	})
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		t.Fatalf("expected gorm.ErrRecordNotFound for status mismatch, got %v", err)
	}
}

func TestFileManagerFileRepositoryDelete(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "Delete.bin",
		NameNormalized: "delete.bin",
		MimeType:       "application/octet-stream",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "live",
		Status:         filemanager.FileStatusReady,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	deletedBy := uuidv7.MustBytes()
	purgeAt := time.Now().UTC().Add(72 * time.Hour).Truncate(time.Second)
	if err := repo.Delete(context.Background(), row.ID, deletedBy, &purgeAt); err != nil {
		t.Fatalf("delete file: %v", err)
	}

	active, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get after delete: %v", err)
	}
	if active != nil {
		t.Fatalf("expected deleted row hidden from GetByID")
	}

	var stored filemanager.File
	if err := db.WithContext(context.Background()).Unscoped().Where("id = ?", row.ID).First(&stored).Error; err != nil {
		t.Fatalf("unscoped find: %v", err)
	}
	if stored.DeletedAt == nil || stored.TrashedAt == nil {
		t.Fatalf("expected deleted_at and trashed_at populated")
	}
	if stored.Status != filemanager.FileStatusTrashed {
		t.Fatalf("expected status trashed, got %s", stored.Status)
	}
	if stored.PurgeAt == nil || !stored.PurgeAt.Equal(purgeAt) {
		t.Fatalf("expected purge_at set")
	}
	if !bytes.Equal(stored.DeletedBy, deletedBy) {
		t.Fatalf("expected deleted_by set")
	}
	if stored.NameSlot == "live" {
		t.Fatalf("expected name_slot switched from live on delete")
	}
}

func TestFileManagerFileRepositoryDelete_OnlyReadyAllowed(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "docs")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "NotReady.bin",
		NameNormalized: "notready.bin",
		MimeType:       "application/octet-stream",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "tmp-slot",
		Status:         filemanager.FileStatusValidated,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("seed create: %v", err)
	}

	err := repo.Delete(context.Background(), row.ID, uuidv7.MustBytes(), nil)
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		t.Fatalf("expected gorm.ErrRecordNotFound for non-ready delete, got %v", err)
	}
}

func TestFileManagerFileRepositoryRestorePurgeAndTrashQueries(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "archive")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "Archive.txt",
		NameNormalized: "archive.txt",
		MimeType:       "text/plain",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "live",
		Status:         filemanager.FileStatusReady,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("create file: %v", err)
	}
	deletedBy := uuidv7.MustBytes()
	if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
		t.Fatalf("delete file: %v", err)
	}

	anyRow, err := repo.GetByIDAny(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get by id any: %v", err)
	}
	if anyRow == nil || anyRow.DeletedAt == nil {
		t.Fatalf("expected deleted row from GetByIDAny")
	}

	trashRows, trashTotal, err := repo.ListTrashByFolder(context.Background(), folder.ID, "", 10, 0)
	if err != nil {
		t.Fatalf("list trash by folder: %v", err)
	}
	if trashTotal != 1 || len(trashRows) != 1 {
		t.Fatalf("expected one trashed file")
	}

	restoreBy := uuidv7.MustBytes()
	if err := repo.Restore(context.Background(), row.ID, restoreBy); err != nil {
		t.Fatalf("restore file: %v", err)
	}
	restored, err := repo.GetByID(context.Background(), row.ID)
	if err != nil {
		t.Fatalf("get restored file: %v", err)
	}
	if restored == nil || restored.DeletedAt != nil || restored.NameSlot != "live" {
		t.Fatalf("expected restored live file")
	}

	if err := repo.Delete(context.Background(), row.ID, deletedBy, nil); err != nil {
		t.Fatalf("delete file 2: %v", err)
	}
	if err := repo.Purge(context.Background(), row.ID); err != nil {
		t.Fatalf("purge file: %v", err)
	}
	if got, err := repo.GetByIDAny(context.Background(), row.ID); err != nil || got != nil {
		t.Fatalf("expected purged file gone, got=%v err=%v", got, err)
	}
	if err := repo.Purge(context.Background(), row.ID); err == nil {
		t.Fatalf("expected purge not found on second call")
	}
}

func TestFileManagerFileRepositoryPurge_DeletesActiveFileRow(t *testing.T) {
	db := openFileManagerFileRepoTestDB(t)
	repo := NewFileManagerFileRepository(db)
	folder := seedFolderForFileRepo(t, db, "active")

	row := &filemanager.File{
		FolderID:       folder.ID,
		Name:           "InProgress.txt",
		NameNormalized: "inprogress.txt",
		MimeType:       "text/plain",
		Bucket:         "wucher-file-dev",
		ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
		NameSlot:       "live",
		Status:         filemanager.FileStatusValidated,
	}
	if err := repo.Create(context.Background(), row); err != nil {
		t.Fatalf("create file: %v", err)
	}

	if err := repo.Purge(context.Background(), row.ID); err != nil {
		t.Fatalf("purge active file: %v", err)
	}
	if got, err := repo.GetByIDAny(context.Background(), row.ID); err != nil || got != nil {
		t.Fatalf("expected purged active file gone, got=%v err=%v", got, err)
	}
}

func TestFileManagerFileRepositoryListByFolder(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		db := openFileManagerFileRepoTestDB(t)
		repo := NewFileManagerFileRepository(db)
		folder := seedFolderForFileRepo(t, db, "docs")

		_ = repo.Create(context.Background(), &filemanager.File{
			FolderID:       folder.ID,
			Name:           "B.txt",
			NameNormalized: "b.txt",
			MimeType:       "text/plain",
			Bucket:         "wucher-file-dev",
			ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
			NameSlot:       "live",
			Status:         filemanager.FileStatusReady,
		})
		_ = repo.Create(context.Background(), &filemanager.File{
			FolderID:       folder.ID,
			Name:           "A.txt",
			NameNormalized: "a.txt",
			MimeType:       "text/plain",
			Bucket:         "wucher-file-dev",
			ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()+1),
			NameSlot:       "live",
			Status:         filemanager.FileStatusReady,
		})

		rows, total, err := repo.ListByFolder(context.Background(), folder.ID, "name ASC", 1, 0)
		if err != nil {
			t.Fatalf("list files: %v", err)
		}
		if total != 2 {
			t.Fatalf("expected total 2, got %d", total)
		}
		if len(rows) != 1 || rows[0].Name != "A.txt" {
			t.Fatalf("expected sorted+limited list")
		}
	})

	t.Run("count error", func(t *testing.T) {
		db := openFileManagerFileRepoTestDB(t)
		repo := NewFileManagerFileRepository(db)
		folder := seedFolderForFileRepo(t, db, "docs")
		if err := db.Migrator().DropTable(&filemanager.File{}); err != nil {
			t.Fatalf("drop table: %v", err)
		}
		if _, _, err := repo.ListByFolder(context.Background(), folder.ID, "", 10, 0); err == nil {
			t.Fatalf("expected count error")
		}
	})

	t.Run("find error", func(t *testing.T) {
		db := openFileManagerFileRepoTestDB(t)
		repo := NewFileManagerFileRepository(db)
		folder := seedFolderForFileRepo(t, db, "docs")
		_ = repo.Create(context.Background(), &filemanager.File{
			FolderID:       folder.ID,
			Name:           "X.txt",
			NameNormalized: "x.txt",
			MimeType:       "text/plain",
			Bucket:         "wucher-file-dev",
			ObjectKey:      fmt.Sprintf("fm/objects/%d", time.Now().UnixNano()),
			NameSlot:       "live",
			Status:         filemanager.FileStatusReady,
		})
		if _, _, err := repo.ListByFolder(context.Background(), folder.ID, "name ASC, )", 10, 0); err == nil {
			t.Fatalf("expected find error")
		}
	})
}
