package service

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"path/filepath"
	"sort"
	"strings"
	"time"
	"unicode/utf8"

	"gorm.io/gorm"

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

type FileManagerService struct {
	folderRepo           filemanager.FolderRepository
	fileRepo             filemanager.FileRepository
	attachmentRepo       filemanager.AttachmentRepository
	uploadIntentRepo     filemanager.FileUploadIntentRepository
	storage              filemanager.ObjectStorage
	uploadPolicy         filemanager.FileUploadPolicy
	fileQueue            filemanager.FileProcessingQueue
	fileOutbox           filemanager.FileProcessingOutboxWriter
	fileOutboxSerializer queue.FileProcessingMessageSerializer
	fileStatusEvents     filemanager.FileStatusRealtimeEventWriter
	trashRetention       time.Duration
	logger               *slog.Logger
	enableS3Tagging      bool
}

type FileManagerServiceDependencies struct {
	UploadIntentRepo     filemanager.FileUploadIntentRepository
	Storage              filemanager.ObjectStorage
	UploadPolicy         filemanager.FileUploadPolicy
	FileQueue            filemanager.FileProcessingQueue
	FileOutbox           filemanager.FileProcessingOutboxWriter
	FileOutboxSerializer queue.FileProcessingMessageSerializer
	FileStatusEvents     filemanager.FileStatusRealtimeEventWriter
	TrashRetention       time.Duration
	Logger               *slog.Logger
	EnableS3Tagging      bool
}

func NewFileManagerService(
	folderRepo filemanager.FolderRepository,
	fileRepo filemanager.FileRepository,
	attachmentRepo filemanager.AttachmentRepository,
	deps FileManagerServiceDependencies,
) *FileManagerService {
	svc := &FileManagerService{
		folderRepo:           folderRepo,
		fileRepo:             fileRepo,
		attachmentRepo:       attachmentRepo,
		uploadIntentRepo:     deps.UploadIntentRepo,
		storage:              deps.Storage,
		fileQueue:            deps.FileQueue,
		fileOutbox:           deps.FileOutbox,
		fileOutboxSerializer: deps.FileOutboxSerializer,
		fileStatusEvents:     deps.FileStatusEvents,
		trashRetention:       30 * 24 * time.Hour,
		logger:               deps.Logger,
		enableS3Tagging:      deps.EnableS3Tagging,
	}
	if svc.logger == nil {
		svc.logger = slog.Default()
	}
	if deps.TrashRetention >= 0 {
		svc.trashRetention = deps.TrashRetention
	}
	if svc.uploadIntentRepo == nil {
		uploadIntentRepo, ok := fileRepo.(filemanager.FileUploadIntentRepository)
		if ok {
			svc.uploadIntentRepo = uploadIntentRepo
		}
	}
	svc.SetUploadPolicy(deps.UploadPolicy)
	return svc
}

func (s *FileManagerService) SetTrashRetention(retention time.Duration) {
	if retention < 0 {
		retention = 0
	}
	s.trashRetention = retention
}

func (s *FileManagerService) defaultTrashPurgeAt(now time.Time) *time.Time {
	if s.trashRetention <= 0 {
		return nil
	}
	v := now.UTC().Add(s.trashRetention)
	return &v
}

func (s *FileManagerService) SetObjectStorage(storage filemanager.ObjectStorage) {
	s.storage = storage
}

func (s *FileManagerService) SetUploadPolicy(policy filemanager.FileUploadPolicy) {
	s.uploadPolicy = normalizeUploadPolicy(policy)
}

func (s *FileManagerService) SetFileProcessingQueue(fileQueue filemanager.FileProcessingQueue) {
	s.fileQueue = fileQueue
}

func (s *FileManagerService) SetFileUploadIntentRepository(repo filemanager.FileUploadIntentRepository) {
	s.uploadIntentRepo = repo
}

func (s *FileManagerService) SetFileProcessingOutbox(
	outbox filemanager.FileProcessingOutboxWriter,
	serializer queue.FileProcessingMessageSerializer,
) {
	s.fileOutbox = outbox
	s.fileOutboxSerializer = serializer
}

func (s *FileManagerService) SetFileStatusRealtimeEventWriter(writer filemanager.FileStatusRealtimeEventWriter) {
	s.fileStatusEvents = writer
}

func (s *FileManagerService) GetOrCreateSystemRootFolder(ctx context.Context) (*filemanager.Folder, error) {
	if s.folderRepo == nil {
		return nil, filemanager.ErrFolderNotFound
	}

	root, err := s.folderRepo.GetByParentAndName(ctx, nil, filemanager.SystemRootFolderName)
	if err != nil {
		return nil, err
	}
	if root != nil {
		return root, nil
	}

	row := &filemanager.Folder{
		ParentID:       nil,
		Name:           filemanager.SystemRootFolderName,
		NameNormalized: filemanager.SystemRootFolderName,
		Depth:          0,
		PathCache:      "/" + filemanager.SystemRootFolderName,
		NameSlot:       "live",
	}
	if err := s.folderRepo.Create(ctx, row); err != nil {
		// If another request created it first, re-read.
		root, getErr := s.folderRepo.GetByParentAndName(ctx, nil, filemanager.SystemRootFolderName)
		if getErr == nil && root != nil {
			return root, nil
		}
		return nil, err
	}
	return row, nil
}

func (s *FileManagerService) CreateFolderNode(ctx context.Context, params filemanager.CreateFolderParams) (*filemanager.Folder, error) {
	name, nameNormalized, err := normalizeFolderName(params.Name)
	if err != nil {
		return nil, err
	}

	var parentID []byte
	depth := 0
	pathCache := buildFolderPathCache("", name)
	if len(params.ParentID) != 0 {
		parent, err := s.folderRepo.GetByID(ctx, params.ParentID)
		if err != nil {
			return nil, err
		}
		if parent == nil {
			return nil, filemanager.ErrFolderNotFound
		}
		parentID = append([]byte(nil), params.ParentID...)
		depth = parent.Depth + 1
		pathCache = buildFolderPathCache(parent.PathCache, name)
	}

	dup, err := s.folderRepo.GetByParentAndName(ctx, parentID, nameNormalized)
	if err != nil {
		return nil, err
	}
	if dup != nil {
		return nil, filemanager.ErrDuplicateName
	}

	row := &filemanager.Folder{
		ParentID:       parentID,
		Name:           name,
		NameNormalized: nameNormalized,
		Depth:          depth,
		PathCache:      pathCache,
		NameSlot:       "live",
		CreatedBy:      params.ActorID,
		UpdatedBy:      params.ActorID,
	}
	if err := s.folderRepo.Create(ctx, row); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return nil, filemanager.ErrDuplicateName
		}
		return nil, err
	}

	created, err := s.folderRepo.GetByID(ctx, row.ID)
	if err == nil && created != nil {
		return created, nil
	}
	return row, nil
}

func (s *FileManagerService) MoveFolderNode(ctx context.Context, id []byte, params filemanager.MoveFolderParams) (*filemanager.Folder, error) {
	current, err := s.folderRepo.GetByIDAny(ctx, id)
	if err != nil {
		return nil, err
	}
	if current == nil {
		return nil, filemanager.ErrFolderNotFound
	}
	if current.DeletedAt != nil {
		return nil, fmt.Errorf("%w: folder in trash cannot be moved", filemanager.ErrInvalidMove)
	}

	targetName := current.Name
	targetNameNormalized := current.NameNormalized
	if params.Name != nil {
		name, nameNormalized, err := normalizeFolderName(*params.Name)
		if err != nil {
			return nil, err
		}
		targetName = name
		targetNameNormalized = nameNormalized
	}

	targetParentID := current.ParentID
	if params.ParentProvided {
		targetParentID = append([]byte(nil), params.ParentID...)
		if len(targetParentID) != 0 {
			parent, err := s.folderRepo.GetByIDAny(ctx, targetParentID)
			if err != nil {
				return nil, err
			}
			if parent == nil {
				return nil, filemanager.ErrFolderNotFound
			}
			if parent.DeletedAt != nil {
				return nil, fmt.Errorf("%w: destination folder in trash cannot be used", filemanager.ErrInvalidMove)
			}
		}
	}

	moved, err := s.folderRepo.MoveSubtree(ctx, id, targetParentID, targetName, targetNameNormalized, params.ActorID)
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, filemanager.ErrFolderNotFound
		}
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return nil, filemanager.ErrDuplicateName
		}
		if errors.Is(err, filemanager.ErrInvalidMove) {
			return nil, filemanager.ErrInvalidMove
		}
		return nil, err
	}
	if moved == nil {
		return nil, filemanager.ErrFolderNotFound
	}
	return moved, nil
}

func (s *FileManagerService) RequestFileUploadIntent(ctx context.Context, params filemanager.RequestFileUploadIntentParams) (*filemanager.FileUploadIntentGrant, error) {
	if s.storage == nil {
		return nil, filemanager.ErrStorageUnavailable
	}
	if s.uploadIntentRepo == nil {
		return nil, filemanager.ErrUploadIntentRepositoryDisabled
	}

	name, nameNormalized, err := normalizeFileName(params.Name)
	if err != nil {
		return nil, err
	}

	contentType := normalizeUploadedMimeType(params.ContentType)
	if err := s.validateUploadAgainstPolicy(name, contentType, params.SizeBytes); err != nil {
		return nil, err
	}

	intentID := uuidv7.MustBytes()
	objectKey := buildImmutableFileObjectKey(name)
	grant, err := s.storage.PresignPutObject(ctx, filemanager.PresignPutObjectInput{
		ObjectKey:   objectKey,
		ContentType: contentType,
		SizeBytes:   params.SizeBytes,
	})
	if err != nil {
		return nil, err
	}

	expiresAt := grant.ExpiresAt.UTC()
	if expiresAt.IsZero() {
		expiresAt = time.Now().UTC().Add(15 * time.Minute)
	}
	bucket := strings.TrimSpace(grant.Bucket)
	key := strings.TrimSpace(grant.ObjectKey)
	if key == "" {
		key = objectKey
	}
	method := strings.ToUpper(strings.TrimSpace(grant.Method))
	if method == "" {
		method = "PUT"
	}

	intent := &filemanager.FileUploadIntent{
		ID:             intentID,
		Name:           name,
		NameNormalized: nameNormalized,
		Extension:      extensionFromName(name),
		SizeBytes:      params.SizeBytes,
		MimeType:       contentType,
		Bucket:         bucket,
		ObjectKey:      key,
		Status:         filemanager.FileUploadIntentStatusPending,
		ExpiresAt:      expiresAt,
		CreatedBy:      params.ActorID,
		UpdatedBy:      params.ActorID,
	}
	if err := s.uploadIntentRepo.CreateUploadIntent(ctx, intent); err != nil {
		return nil, err
	}

	return &filemanager.FileUploadIntentGrant{
		UploadIntentID: append([]byte(nil), intent.ID...),
		Bucket:         intent.Bucket,
		ObjectKey:      intent.ObjectKey,
		Name:           intent.Name,
		MimeType:       intent.MimeType,
		SizeBytes:      intent.SizeBytes,
		Method:         method,
		UploadURL:      strings.TrimSpace(grant.URL),
		Headers:        cloneTrimmedStringMap(grant.Headers),
		ExpiresAt:      intent.ExpiresAt.UTC(),
	}, nil
}

func (s *FileManagerService) UpdateUploadIntentSize(ctx context.Context, params filemanager.FileUploadIntentSizeUpdateParams) error {
	if s.uploadIntentRepo == nil {
		return filemanager.ErrUploadIntentRepositoryDisabled
	}
	updater, ok := s.uploadIntentRepo.(interface {
		UpdateUploadIntentSize(context.Context, filemanager.FileUploadIntentSizeUpdateParams) error
	})
	if !ok {
		return filemanager.ErrUploadIntentRepositoryDisabled
	}
	return updater.UpdateUploadIntentSize(ctx, params)
}

func (s *FileManagerService) CompleteFileUploadIntent(ctx context.Context, params filemanager.CompleteFileUploadIntentParams) (*filemanager.File, error) {
	if s.storage == nil {
		return nil, filemanager.ErrStorageUnavailable
	}
	if s.uploadIntentRepo == nil {
		return nil, filemanager.ErrUploadIntentRepositoryDisabled
	}

	intent, err := s.uploadIntentRepo.GetUploadIntentByID(ctx, params.UploadIntentID)
	if err != nil {
		return nil, err
	}
	if intent == nil {
		return nil, filemanager.ErrUploadIntentNotFound
	}

	status := filemanager.NormalizeFileUploadIntentStatus(intent.Status)
	if status == filemanager.FileUploadIntentStatusCompleted {
		row, err := s.fileRepo.GetByObjectKey(ctx, intent.ObjectKey)
		if err != nil {
			return nil, err
		}
		if row != nil {
			if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil {
				return nil, touchErr
			}
			return row, nil
		}
		return nil, filemanager.ErrUploadIntentAlreadyComplete
	}
	if status == filemanager.FileUploadIntentStatusExpired {
		return nil, filemanager.ErrUploadIntentExpired
	}
	if status != filemanager.FileUploadIntentStatusPending {
		return nil, filemanager.ErrUploadIntentStatusConflict
	}

	now := time.Now().UTC()
	if !intent.ExpiresAt.IsZero() && now.After(intent.ExpiresAt.UTC()) {
		_ = s.uploadIntentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{
			ID:        intent.ID,
			UpdatedBy: params.ActorID,
			ExpiredAt: now,
		})
		return nil, filemanager.ErrUploadIntentExpired
	}

	headMeta, err := s.verifyUploadedObjectForIntent(ctx, intent)
	if err != nil {
		return nil, err
	}

	if txRepo, ok := s.fileRepo.(filemanager.FileTransactionalRepository); ok && s.fileOutboxReady() {
		row, err := s.completeFileUploadIntentDurable(ctx, txRepo, intent, headMeta, params.ActorID)
		if err != nil {
			return nil, err
		}
		if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil {
			return nil, touchErr
		}
		s.ensureLifecycleTempMarker(ctx, row)
		s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusUploaded, params.ActorID, "")
		return row, nil
	}

	row, err := s.createUploadedFileFromIntent(ctx, intent, headMeta, params.ActorID)
	if err != nil {
		return nil, err
	}
	if err := s.uploadIntentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{
		ID:          intent.ID,
		UpdatedBy:   params.ActorID,
		CompletedAt: time.Now().UTC(),
	}); err != nil {
		return nil, err
	}
	if touchErr := s.touchFolders(ctx, params.ActorID, row.FolderID); touchErr != nil {
		return nil, touchErr
	}
	s.ensureLifecycleTempMarker(ctx, row)

	s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusUploaded, params.ActorID, "")
	if err := s.enqueueUploadedFileForProcessing(ctx, row, params.ActorID); err != nil {
		failedAt := time.Now().UTC()
		reason := truncateFailureReason(err.Error())
		_ = s.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
			ID:            row.ID,
			ExpectedFrom:  filemanager.FileStatusUploaded,
			To:            filemanager.FileStatusFailed,
			UpdatedBy:     params.ActorID,
			ProcessedAt:   &failedAt,
			FailedAt:      &failedAt,
			FailureReason: &reason,
		})
		row.Status = filemanager.FileStatusFailed
		row.FailureReason = reason
		row.UpdatedAt = failedAt
		row.UpdatedBy = append([]byte(nil), params.ActorID...)
		s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusFailed, params.ActorID, reason)
		return nil, err
	}

	return row, nil
}

func (s *FileManagerService) CancelFileUploadIntent(ctx context.Context, params filemanager.CancelFileUploadIntentParams) error {
	if s.uploadIntentRepo == nil {
		return filemanager.ErrUploadIntentRepositoryDisabled
	}

	intent, err := s.uploadIntentRepo.GetUploadIntentByID(ctx, params.UploadIntentID)
	if err != nil {
		return err
	}
	if intent == nil {
		return filemanager.ErrUploadIntentNotFound
	}

	status := filemanager.NormalizeFileUploadIntentStatus(intent.Status)
	switch status {
	case filemanager.FileUploadIntentStatusExpired:
		// Idempotent: already canceled/expired.
		return nil
	case filemanager.FileUploadIntentStatusCompleted:
		return filemanager.ErrUploadIntentAlreadyComplete
	case filemanager.FileUploadIntentStatusPending:
		// Continue to cancellation flow.
	default:
		return filemanager.ErrUploadIntentStatusConflict
	}

	if s.storage == nil {
		return filemanager.ErrStorageUnavailable
	}

	if strings.TrimSpace(intent.ObjectKey) != "" {
		if err := s.storage.DeleteObject(ctx, intent.ObjectKey); err != nil {
			return err
		}
	}

	now := time.Now().UTC()
	if err := s.uploadIntentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{
		ID:        intent.ID,
		UpdatedBy: params.ActorID,
		ExpiredAt: now,
	}); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return filemanager.ErrUploadIntentStatusConflict
		}
		return err
	}

	return nil
}

func (s *FileManagerService) UploadFileNode(ctx context.Context, params filemanager.UploadFileParams) (*filemanager.File, error) {
	if s.storage == nil {
		return nil, filemanager.ErrStorageUnavailable
	}
	if params.Body == nil {
		return nil, filemanager.ErrInvalidName
	}

	name, nameNormalized, err := normalizeFileName(params.Name)
	if err != nil {
		return nil, err
	}

	contentType := normalizeUploadedMimeType(params.ContentType)
	if err := s.validateUploadAgainstPolicy(name, contentType, params.SizeBytes); err != nil {
		return nil, err
	}

	// Upload stage always parks metadata under hidden system root.
	// The final logical folder/name is set by CreateFileNodeFromUpload.
	root, err := s.GetOrCreateSystemRootFolder(ctx)
	if err != nil {
		return nil, err
	}
	targetFolderID := root.ID

	objectKey := buildImmutableFileObjectKey(name)
	validatedAt := time.Now().UTC()
	metadata := map[string]string{
		"uploaded": validatedAt.Format(time.RFC3339),
	}
	if folderIDStr, err := uuidv7.BytesToString(targetFolderID); err == nil {
		metadata["folder_id"] = folderIDStr
	}
	meta, err := s.storage.PutObject(ctx, filemanager.PutObjectInput{
		ObjectKey:   objectKey,
		ContentType: contentType,
		SizeBytes:   params.SizeBytes,
		Body:        params.Body,
		Metadata:    metadata,
	})
	if err != nil {
		return nil, err
	}

	row := &filemanager.File{
		ID:                  uuidv7.MustBytes(),
		FolderID:            targetFolderID,
		Name:                name,
		NameNormalized:      nameNormalized,
		Extension:           extensionFromName(name),
		SizeBytes:           params.SizeBytes,
		MimeType:            contentType,
		Bucket:              meta.Bucket,
		ObjectKey:           meta.ObjectKey,
		ObjectVersion:       meta.VersionID,
		ETag:                meta.ETag,
		IsTemplate:          params.IsTemplate,
		Status:              filemanager.FileStatusUploaded,
		LifecycleStatus:     filemanager.FileLifecycleStatusTemp,
		ProcessingStartedAt: nil,
		ProcessedAt:         nil,
		ValidatedAt:         nil,
		NameSlot:            tempUploadNameSlot(),
		CreatedBy:           params.ActorID,
		UpdatedBy:           params.ActorID,
	}

	if txRepo, ok := s.fileRepo.(filemanager.FileTransactionalRepository); ok && s.fileOutboxReady() {
		if err := txRepo.WithTransaction(ctx, func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error {
			if err := repo.Create(ctx, row); err != nil {
				return err
			}
			return s.persistFileProcessingOutbox(ctx, outbox, row, params.ActorID)
		}); err != nil {
			_ = s.storage.DeleteObject(ctx, meta.ObjectKey)
			return nil, err
		}

		createdRow := row
		created, err := s.fileRepo.GetByID(ctx, row.ID)
		if err == nil && created != nil {
			createdRow = created
		}
		s.ensureLifecycleTempMarker(ctx, createdRow)
		s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusUploaded, params.ActorID, "")
		return createdRow, nil
	}

	if err := s.fileRepo.Create(ctx, row); err != nil {
		_ = s.storage.DeleteObject(ctx, meta.ObjectKey)
		return nil, err
	}

	createdRow := row
	created, err := s.fileRepo.GetByID(ctx, row.ID)
	if err == nil && created != nil {
		createdRow = created
	}
	if touchErr := s.touchFolders(ctx, params.ActorID, createdRow.FolderID); touchErr != nil {
		return nil, touchErr
	}
	s.ensureLifecycleTempMarker(ctx, createdRow)
	s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusUploaded, params.ActorID, "")

	if err := s.enqueueUploadedFileForProcessing(ctx, createdRow, params.ActorID); err != nil {
		now := time.Now().UTC()
		reason := truncateFailureReason(err.Error())
		_ = s.fileRepo.TransitionStatus(ctx, filemanager.FileStatusTransitionParams{
			ID:            createdRow.ID,
			ExpectedFrom:  filemanager.FileStatusUploaded,
			To:            filemanager.FileStatusFailed,
			UpdatedBy:     params.ActorID,
			ProcessedAt:   &now,
			FailedAt:      &now,
			FailureReason: &reason,
		})
		createdRow.Status = filemanager.FileStatusFailed
		createdRow.FailureReason = reason
		createdRow.UpdatedBy = append([]byte(nil), params.ActorID...)
		createdRow.UpdatedAt = now
		s.emitFileStatusRealtimeEvent(ctx, createdRow, filemanager.FileStatusFailed, params.ActorID, reason)
		return nil, err
	}

	return createdRow, nil
}

func (s *FileManagerService) CreateFileNodeFromUploadIntent(ctx context.Context, params filemanager.CreateFileFromUploadIntentParams) (*filemanager.File, error) {
	name, _, err := normalizeFileName(params.Name)
	if err != nil {
		return nil, err
	}

	targetFolderID, err := s.resolveTargetFolderID(ctx, params.FolderID, params.FolderProvided, true)
	if err != nil {
		return nil, err
	}

	uploaded, err := s.CompleteFileUploadIntent(ctx, filemanager.CompleteFileUploadIntentParams{
		UploadIntentID: params.UploadIntentID,
		ActorID:        params.ActorID,
	})
	if err != nil {
		return nil, err
	}
	if uploaded == nil {
		return nil, filemanager.ErrFileNotFound
	}

	currentStatus := filemanager.NormalizeFileStatus(uploaded.Status)
	if strings.EqualFold(strings.TrimSpace(uploaded.NameSlot), "live") && currentStatus == filemanager.FileStatusReady {
		return nil, filemanager.ErrFileAlreadyFinalized
	}
	if uploaded.IsTemplate != params.IsTemplate {
		uploaded.IsTemplate = params.IsTemplate
		if err := s.fileRepo.Update(ctx, uploaded); err != nil {
			return nil, err
		}
	}
	targetName, _, err := s.resolveAvailableFileName(ctx, targetFolderID, name, uploaded.ID)
	if err != nil {
		return nil, err
	}

	moved, err := s.MoveFileNode(ctx, uploaded.ID, filemanager.MoveFileParams{
		Name:           &targetName,
		FolderID:       targetFolderID,
		FolderProvided: true,
		ActorID:        params.ActorID,
	})
	if err != nil {
		return nil, err
	}

	return moved, nil
}

func (s *FileManagerService) CreateFileNodeFromUpload(ctx context.Context, params filemanager.CreateFileFromUploadParams) (*filemanager.File, error) {
	current, err := s.fileRepo.GetByID(ctx, params.UploadFileID)
	if err != nil {
		return nil, err
	}
	if current == nil {
		return nil, filemanager.ErrFileNotFound
	}
	currentStatus := filemanager.NormalizeFileStatus(current.Status)
	if strings.EqualFold(strings.TrimSpace(current.NameSlot), "live") &&
		currentStatus == filemanager.FileStatusReady {
		return nil, filemanager.ErrFileAlreadyFinalized
	}
	if err := ensureFileStatusTransition(currentStatus, filemanager.FileStatusReady); err != nil {
		return nil, fmt.Errorf("%w: current status is %q", filemanager.ErrFileNotEligibleForFinalize, currentStatus)
	}

	targetFolderID := current.FolderID
	if params.FolderProvided {
		folderID, err := s.resolveTargetFolderID(ctx, params.FolderID, true, true)
		if err != nil {
			return nil, err
		}
		targetFolderID = folderID
	}

	targetName := current.Name
	if params.Name != nil {
		name, _, err := normalizeFileName(*params.Name)
		if err != nil {
			return nil, err
		}
		targetName = name
	}

	targetName, targetNameNormalized, err := s.resolveAvailableFileName(ctx, targetFolderID, targetName, current.ID)
	if err != nil {
		return nil, err
	}

	if err := s.fileRepo.Finalize(ctx, filemanager.FileFinalizeParams{
		ID:             current.ID,
		ExpectedFrom:   currentStatus,
		FolderID:       targetFolderID,
		Name:           targetName,
		NameNormalized: targetNameNormalized,
		Extension:      extensionFromName(targetName),
		UpdatedBy:      params.ActorID,
	}); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return nil, filemanager.ErrDuplicateName
		}
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, filemanager.ErrFileStatusConflict
		}
		return nil, err
	}

	updated, err := s.fileRepo.GetByID(ctx, params.UploadFileID)
	if err == nil && updated != nil {
		if touchErr := s.touchFolders(ctx, params.ActorID, updated.FolderID); touchErr != nil {
			return nil, touchErr
		}
		s.emitFileStatusRealtimeEvent(ctx, updated, filemanager.FileStatusReady, params.ActorID, "")
		return updated, nil
	}
	if err != nil {
		return nil, err
	}
	return nil, filemanager.ErrFileNotFound
}

func (s *FileManagerService) UploadFileNodesBulk(ctx context.Context, params []filemanager.UploadFileParams) []filemanager.BulkFileNodeResult {
	results := make([]filemanager.BulkFileNodeResult, 0, len(params))
	for i := range params {
		row, err := s.UploadFileNode(ctx, params[i])
		results = append(results, filemanager.BulkFileNodeResult{
			Index: i,
			File:  row,
			Err:   err,
		})
	}
	return results
}

func (s *FileManagerService) CreateFileNodesFromUploadBulk(ctx context.Context, params []filemanager.CreateFileFromUploadParams) []filemanager.BulkFileNodeResult {
	results := make([]filemanager.BulkFileNodeResult, 0, len(params))
	for i := range params {
		row, err := s.CreateFileNodeFromUpload(ctx, params[i])
		results = append(results, filemanager.BulkFileNodeResult{
			Index: i,
			File:  row,
			Err:   err,
		})
	}
	return results
}

func (s *FileManagerService) MoveFileNode(ctx context.Context, id []byte, params filemanager.MoveFileParams) (*filemanager.File, error) {
	current, err := s.fileRepo.GetByIDAny(ctx, id)
	if err != nil {
		return nil, err
	}
	if current == nil {
		return nil, filemanager.ErrFileNotFound
	}
	if current.DeletedAt != nil {
		return nil, fmt.Errorf("%w: file in trash cannot be moved", filemanager.ErrInvalidMove)
	}

	targetFolderID := current.FolderID
	sourceFolderID := append([]byte(nil), current.FolderID...)
	if params.FolderProvided {
		targetFolderID = append([]byte(nil), params.FolderID...)
		if len(targetFolderID) != 0 {
			destination, err := s.folderRepo.GetByIDAny(ctx, targetFolderID)
			if err != nil {
				return nil, err
			}
			if destination == nil {
				return nil, filemanager.ErrFolderNotFound
			}
			if destination.DeletedAt != nil {
				return nil, fmt.Errorf("%w: destination folder in trash cannot be used", filemanager.ErrInvalidMove)
			}
		}
	}

	targetName := current.Name
	targetNameNormalized := current.NameNormalized
	if params.Name != nil {
		name, nameNormalized, err := normalizeFileName(*params.Name)
		if err != nil {
			return nil, err
		}
		targetName = name
		targetNameNormalized = nameNormalized
	}

	dup, err := s.fileRepo.GetByFolderAndName(ctx, targetFolderID, targetNameNormalized)
	if err != nil {
		return nil, err
	}
	if dup != nil && !bytesEqual16(dup.ID, current.ID) {
		return nil, filemanager.ErrDuplicateName
	}

	current.FolderID = targetFolderID
	current.Name = targetName
	current.NameNormalized = targetNameNormalized
	current.Extension = extensionFromName(targetName)
	current.NameSlot = "live"
	current.UpdatedBy = params.ActorID

	if err := s.fileRepo.Update(ctx, current); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return nil, filemanager.ErrDuplicateName
		}
		return nil, err
	}
	if err := s.touchFolders(ctx, params.ActorID, sourceFolderID, targetFolderID); err != nil {
		return nil, err
	}

	updated, err := s.fileRepo.GetByID(ctx, current.ID)
	if err == nil && updated != nil {
		return updated, nil
	}
	return current, nil
}

func (s *FileManagerService) PurgeFolderNode(ctx context.Context, id []byte) error {
	row, err := s.folderRepo.GetByIDAny(ctx, id)
	if err != nil {
		return err
	}
	if row == nil || isSystemRootFolder(row) {
		return filemanager.ErrFolderNotFound
	}
	if row.DeletedAt == nil {
		return filemanager.ErrFolderNotInTrash
	}

	subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id)
	if err != nil {
		return err
	}
	if len(subtreeFolders) == 0 {
		return filemanager.ErrFolderNotFound
	}

	folderIDs := make([][]byte, 0, len(subtreeFolders))
	for i := range subtreeFolders {
		folderIDs = append(folderIDs, subtreeFolders[i].ID)
	}

	allFiles, err := s.listAllFilesByFolders(ctx, folderIDs)
	if err != nil {
		return err
	}

	objectKeys := make([]string, 0, len(allFiles))
	for i := range allFiles {
		fileRow := allFiles[i]
		if fileRow.DeletedAt == nil {
			if err := s.fileRepo.Delete(ctx, fileRow.ID, nil, nil); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return err
			}
		}
		if err := s.fileRepo.Purge(ctx, fileRow.ID); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		if key := strings.TrimSpace(fileRow.ObjectKey); key != "" {
			objectKeys = append(objectKeys, key)
		}
		if fileRow.ThumbnailObjectKey != nil {
			if key := strings.TrimSpace(*fileRow.ThumbnailObjectKey); key != "" {
				objectKeys = append(objectKeys, key)
			}
		}
	}

	sort.SliceStable(subtreeFolders, func(i, j int) bool {
		return subtreeFolders[i].Depth > subtreeFolders[j].Depth
	})
	for i := range subtreeFolders {
		folderID := subtreeFolders[i].ID
		if subtreeFolders[i].DeletedAt == nil {
			if err := s.folderRepo.Delete(ctx, folderID, nil, nil); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return err
			}
		}
		if err := s.folderRepo.Purge(ctx, folderID); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
	}

	if s.storage != nil && len(objectKeys) > 0 {
		uniqueKeys := uniqueNonEmptyStrings(objectKeys)
		go func(storage filemanager.ObjectStorage, keys []string) {
			bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
			defer cancel()
			for i := range keys {
				_ = storage.DeleteObject(bgCtx, keys[i])
			}
		}(s.storage, uniqueKeys)
	}

	return nil
}

func (s *FileManagerService) PurgeFileNode(ctx context.Context, id []byte) error {
	row, err := s.fileRepo.GetByIDAny(ctx, id)
	if err != nil {
		return err
	}
	if row == nil {
		return filemanager.ErrFileNotFound
	}
	if row.DeletedAt == nil {
		return filemanager.ErrFileNotInTrash
	}

	objectKeys := make([]string, 0, 2)
	if key := strings.TrimSpace(row.ObjectKey); key != "" {
		objectKeys = append(objectKeys, key)
	}
	if row.ThumbnailObjectKey != nil {
		if key := strings.TrimSpace(*row.ThumbnailObjectKey); key != "" {
			objectKeys = append(objectKeys, key)
		}
	}
	if err := s.fileRepo.Purge(ctx, id); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return filemanager.ErrFileNotFound
		}
		return err
	}

	if s.storage != nil && len(objectKeys) > 0 {
		uniqueKeys := uniqueNonEmptyStrings(objectKeys)
		go func(storage filemanager.ObjectStorage, keys []string) {
			bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
			defer cancel()
			for i := range keys {
				_ = storage.DeleteObject(bgCtx, keys[i])
			}
		}(s.storage, uniqueKeys)
	}
	return nil
}

func (s *FileManagerService) ListNodesByParent(ctx context.Context, parentID []byte) ([]filemanager.Folder, []filemanager.File, error) {
	if len(parentID) != 0 {
		parent, err := s.folderRepo.GetByID(ctx, parentID)
		if err != nil {
			return nil, nil, err
		}
		if parent == nil {
			return nil, nil, filemanager.ErrFolderNotFound
		}
		folders, _, err := s.folderRepo.ListByParent(ctx, parentID, "name ASC", 0, 0)
		if err != nil {
			return nil, nil, err
		}
		files, _, err := s.fileRepo.ListByFolder(ctx, parentID, "name ASC", 0, 0)
		if err != nil {
			return nil, nil, err
		}
		return folders, files, nil
	}

	root, err := s.GetOrCreateSystemRootFolder(ctx)
	if err != nil {
		return nil, nil, err
	}

	rootFolders, _, err := s.folderRepo.ListByParent(ctx, nil, "name ASC", 0, 0)
	if err != nil {
		return nil, nil, err
	}
	systemRootChildren, _, err := s.folderRepo.ListByParent(ctx, root.ID, "name ASC", 0, 0)
	if err != nil {
		return nil, nil, err
	}

	out := make([]filemanager.Folder, 0, len(rootFolders)+len(systemRootChildren))
	seen := make(map[string]struct{}, len(rootFolders)+len(systemRootChildren))
	appendVisibleFolder := func(row filemanager.Folder) {
		if isSystemRootFolder(&row) {
			return
		}
		idKey := string(row.ID)
		if _, ok := seen[idKey]; ok {
			return
		}
		seen[idKey] = struct{}{}
		out = append(out, row)
	}
	for i := range rootFolders {
		appendVisibleFolder(rootFolders[i])
	}
	for i := range systemRootChildren {
		appendVisibleFolder(systemRootChildren[i])
	}
	sort.SliceStable(out, func(i, j int) bool {
		return strings.ToLower(strings.TrimSpace(out[i].Name)) < strings.ToLower(strings.TrimSpace(out[j].Name))
	})

	files, _, err := s.fileRepo.ListByFolder(ctx, root.ID, "name ASC", 0, 0)
	if err != nil {
		return nil, nil, err
	}
	return out, files, nil
}

func (s *FileManagerService) SearchNodes(ctx context.Context, query string) ([]filemanager.Folder, []filemanager.File, error) {
	folders, _, err := s.folderRepo.SearchByName(ctx, query, "name ASC", 0, 0)
	if err != nil {
		return nil, nil, err
	}

	files, _, err := s.fileRepo.SearchByName(ctx, query, "name ASC", 0, 0)
	if err != nil {
		return nil, nil, err
	}

	return folders, files, nil
}

func (s *FileManagerService) ListTrashNodes(ctx context.Context) ([]filemanager.Folder, []filemanager.File, error) {
	folders, _, err := s.folderRepo.ListTrash(ctx, "deleted_at DESC", 0, 0)
	if err != nil {
		return nil, nil, err
	}

	files, _, err := s.fileRepo.ListTrashByFolder(ctx, nil, "deleted_at DESC", 0, 0)
	if err != nil {
		return nil, nil, err
	}

	return folders, files, nil
}

func (s *FileManagerService) resolveTargetFolderID(ctx context.Context, folderID []byte, folderProvided bool, allowRoot bool) ([]byte, error) {
	if folderProvided && len(folderID) != 0 {
		folder, err := s.folderRepo.GetByID(ctx, folderID)
		if err != nil {
			return nil, err
		}
		if folder == nil {
			return nil, filemanager.ErrFolderNotFound
		}
		return folder.ID, nil
	}

	if allowRoot {
		root, err := s.GetOrCreateSystemRootFolder(ctx)
		if err != nil {
			return nil, err
		}
		return root.ID, nil
	}

	return nil, filemanager.ErrFolderNotFound
}

func (s *FileManagerService) CreateFolder(ctx context.Context, row *filemanager.Folder) error {
	return s.folderRepo.Create(ctx, row)
}

func (s *FileManagerService) UpdateFolder(ctx context.Context, row *filemanager.Folder) error {
	return s.folderRepo.Update(ctx, row)
}

func (s *FileManagerService) DeleteFolder(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
	resolvedPurgeAt := purgeAt
	if resolvedPurgeAt == nil {
		resolvedPurgeAt = s.defaultTrashPurgeAt(time.Now().UTC())
	}

	row, err := s.folderRepo.GetByID(ctx, id)
	if err != nil {
		return err
	}
	if row == nil || isSystemRootFolder(row) {
		return filemanager.ErrFolderNotFound
	}

	subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id)
	if err != nil {
		return err
	}
	if len(subtreeFolders) == 0 {
		return filemanager.ErrFolderNotFound
	}

	folderIDs := make([][]byte, 0, len(subtreeFolders))
	for i := range subtreeFolders {
		folderIDs = append(folderIDs, subtreeFolders[i].ID)
	}

	activeFiles, err := s.listActiveFilesByFolders(ctx, folderIDs)
	if err != nil {
		return err
	}
	for i := range activeFiles {
		fileID := append([]byte(nil), activeFiles[i].ID...)
		if err := s.fileRepo.Delete(ctx, fileID, deletedBy, resolvedPurgeAt); err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				continue
			}
			return err
		}
	}

	sort.SliceStable(subtreeFolders, func(i, j int) bool {
		return subtreeFolders[i].Depth > subtreeFolders[j].Depth
	})
	for i := range subtreeFolders {
		if subtreeFolders[i].DeletedAt != nil {
			continue
		}
		folderID := append([]byte(nil), subtreeFolders[i].ID...)
		if err := s.folderRepo.Delete(ctx, folderID, deletedBy, resolvedPurgeAt); err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				continue
			}
			return err
		}
	}
	return nil
}

func (s *FileManagerService) RestoreFolder(ctx context.Context, id []byte, targetParentID []byte, targetProvided bool, restoredBy []byte) error {
	row, err := s.folderRepo.GetByIDAny(ctx, id)
	if err != nil {
		return err
	}
	if row == nil {
		return filemanager.ErrFolderNotFound
	}
	if row.DeletedAt == nil {
		return filemanager.ErrFolderNotInTrash
	}
	if isSystemRootFolder(row) {
		return filemanager.ErrFolderNotFound
	}

	_ = targetParentID
	_ = targetProvided

	var (
		resolvedParentID  []byte
		newRootDepth      int
		newRootParentPath string
	)
	if len(row.ParentID) != 0 {
		parent, err := s.folderRepo.GetByID(ctx, row.ParentID)
		if err != nil {
			return err
		}
		if parent == nil {
			return filemanager.ErrFolderNotFound
		}
		if bytesEqual16(parent.ID, row.ID) {
			return filemanager.ErrInvalidMove
		}
		if !isSystemRootFolder(parent) {
			resolvedParentID = append([]byte(nil), parent.ID...)
			newRootDepth = parent.Depth + 1
			newRootParentPath = parent.PathCache
		}
	}

	dup, err := s.folderRepo.GetByParentAndName(ctx, resolvedParentID, row.NameNormalized)
	if err != nil {
		return err
	}
	if dup != nil && !bytesEqual16(dup.ID, row.ID) {
		return filemanager.ErrDuplicateName
	}

	subtreeFolders, err := s.collectFolderSubtreeAny(ctx, id)
	if err != nil {
		return err
	}
	if len(subtreeFolders) == 0 {
		return filemanager.ErrFolderNotFound
	}
	oldRootPath := strings.TrimSpace(row.PathCache)
	if oldRootPath == "" {
		oldRootPath = buildFolderPathCache("", row.Name)
	}
	newRootPath := buildFolderPathCache(newRootParentPath, row.Name)
	depthDelta := newRootDepth - row.Depth

	sort.SliceStable(subtreeFolders, func(i, j int) bool {
		return subtreeFolders[i].Depth < subtreeFolders[j].Depth
	})

	for i := range subtreeFolders {
		current := subtreeFolders[i]
		updated := current
		if bytesEqual16(current.ID, row.ID) {
			updated.ParentID = append([]byte(nil), resolvedParentID...)
			updated.Depth = newRootDepth
			updated.PathCache = newRootPath
		} else {
			updated.Depth = current.Depth + depthDelta
			if updated.Depth < 0 {
				updated.Depth = 0
			}
			updated.PathCache = replaceFolderPathPrefix(current.PathCache, oldRootPath, newRootPath)
		}

		if current.DeletedAt != nil {
			updated.DeletedAt = nil
			updated.DeletedBy = nil
			updated.PurgeAt = nil
			updated.NameSlot = "live"
		}
		updated.UpdatedBy = restoredBy

		if err := s.folderRepo.Update(ctx, &updated); err != nil {
			if errors.Is(err, gorm.ErrDuplicatedKey) {
				return filemanager.ErrDuplicateName
			}
			return err
		}
	}

	folderIDs := make([][]byte, 0, len(subtreeFolders))
	for i := range subtreeFolders {
		folderIDs = append(folderIDs, subtreeFolders[i].ID)
	}
	trashedFiles, err := s.listTrashedFilesByFolders(ctx, folderIDs)
	if err != nil {
		return err
	}
	for i := range trashedFiles {
		rowFile := trashedFiles[i]
		rowFile.Status = filemanager.FileStatusReady
		rowFile.TrashedAt = nil
		rowFile.DeletedAt = nil
		rowFile.DeletedBy = nil
		rowFile.PurgeAt = nil
		rowFile.NameSlot = "live"
		rowFile.UpdatedBy = restoredBy
		if err := s.fileRepo.Update(ctx, &rowFile); err != nil {
			if errors.Is(err, gorm.ErrDuplicatedKey) {
				return filemanager.ErrDuplicateName
			}
			return err
		}
	}

	return nil
}

func (s *FileManagerService) PurgeFolder(ctx context.Context, id []byte) error {
	return s.folderRepo.Purge(ctx, id)
}

func (s *FileManagerService) GetFolderByID(ctx context.Context, id []byte) (*filemanager.Folder, error) {
	row, err := s.folderRepo.GetByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if row != nil && isSystemRootFolder(row) {
		return nil, nil
	}
	return row, nil
}

func (s *FileManagerService) GetFolderByIDAny(ctx context.Context, id []byte) (*filemanager.Folder, error) {
	row, err := s.folderRepo.GetByIDAny(ctx, id)
	if err != nil {
		return nil, err
	}
	if row != nil && isSystemRootFolder(row) {
		return nil, nil
	}
	return row, nil
}

func (s *FileManagerService) GetFolderByParentAndName(ctx context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) {
	row, err := s.folderRepo.GetByParentAndName(ctx, parentID, nameNormalized)
	if err != nil {
		return nil, err
	}
	if row != nil && isSystemRootFolder(row) {
		return nil, nil
	}
	return row, nil
}

func (s *FileManagerService) ListFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
	rows, total, err := s.folderRepo.ListByParent(ctx, parentID, normalizeFolderSort(sort), limit, offset)
	if err != nil {
		return nil, 0, err
	}
	if len(parentID) != 0 {
		return rows, total, nil
	}

	filtered := make([]filemanager.Folder, 0, len(rows))
	for i := range rows {
		if isSystemRootFolder(&rows[i]) {
			continue
		}
		filtered = append(filtered, rows[i])
	}
	if total > int64(len(rows)-len(filtered)) {
		total -= int64(len(rows) - len(filtered))
	}
	return filtered, total, nil
}

func (s *FileManagerService) ListTrashFoldersByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
	return s.folderRepo.ListTrashByParent(ctx, parentID, normalizeFolderSort(sort), limit, offset)
}

func (s *FileManagerService) CreateFile(ctx context.Context, row *filemanager.File) error {
	if err := s.fileRepo.Create(ctx, row); err != nil {
		return err
	}
	actorID := row.UpdatedBy
	if len(actorID) == 0 {
		actorID = row.CreatedBy
	}
	return s.touchFolders(ctx, actorID, row.FolderID)
}

func (s *FileManagerService) UpdateFile(ctx context.Context, row *filemanager.File) error {
	return s.fileRepo.Update(ctx, row)
}

func (s *FileManagerService) DeleteFile(ctx context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
	resolvedPurgeAt := purgeAt
	if resolvedPurgeAt == nil {
		resolvedPurgeAt = s.defaultTrashPurgeAt(time.Now().UTC())
	}

	row, err := s.fileRepo.GetByIDAny(ctx, id)
	if err != nil {
		return err
	}
	if row == nil || row.DeletedAt != nil {
		return filemanager.ErrFileNotFound
	}

	if err := s.fileRepo.Delete(ctx, id, deletedBy, resolvedPurgeAt); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return filemanager.ErrFileNotFound
		}
		return err
	}
	if err := s.touchFolders(ctx, deletedBy, row.FolderID); err != nil {
		return err
	}

	deletedAt := time.Now().UTC()
	row.Status = filemanager.FileStatusTrashed
	row.TrashedAt = &deletedAt
	row.DeletedAt = &deletedAt
	row.DeletedBy = append([]byte(nil), deletedBy...)
	row.UpdatedBy = append([]byte(nil), deletedBy...)
	row.UpdatedAt = deletedAt
	if resolvedPurgeAt != nil {
		purgeAtCopy := resolvedPurgeAt.UTC()
		row.PurgeAt = &purgeAtCopy
	}
	s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusTrashed, deletedBy, "")
	return nil
}

func (s *FileManagerService) PurgeExpiredTrash(ctx context.Context, now time.Time, limit int) (int, int, error) {
	if limit == 0 {
		limit = 200
	}
	if limit < 0 {
		limit = 0
	}

	shouldPurge := func(deletedAt, purgeAt *time.Time) bool {
		if deletedAt == nil {
			return false
		}
		if purgeAt != nil {
			return !purgeAt.UTC().After(now.UTC())
		}
		if s.trashRetention <= 0 {
			return false
		}
		dueAt := deletedAt.UTC().Add(s.trashRetention)
		return !dueAt.After(now.UTC())
	}

	files, _, err := s.fileRepo.ListTrashByFolder(ctx, nil, "deleted_at ASC", 0, 0)
	if err != nil {
		return 0, 0, err
	}

	dueFileIDs := make([][]byte, 0, len(files))
	for i := range files {
		if shouldPurge(files[i].DeletedAt, files[i].PurgeAt) {
			dueFileIDs = append(dueFileIDs, append([]byte(nil), files[i].ID...))
		}
	}

	folders, _, err := s.folderRepo.ListTrash(ctx, "deleted_at ASC", 0, 0)
	if err != nil {
		return 0, 0, err
	}

	dueFolders := make([]filemanager.Folder, 0, len(folders))
	for i := range folders {
		if shouldPurge(folders[i].DeletedAt, folders[i].PurgeAt) {
			dueFolders = append(dueFolders, folders[i])
		}
	}
	sort.SliceStable(dueFolders, func(i, j int) bool {
		return dueFolders[i].Depth < dueFolders[j].Depth
	})

	purgedFiles := 0
	purgedFolders := 0
	maxOps := limit
	ops := 0

	for i := range dueFileIDs {
		if maxOps > 0 && ops >= maxOps {
			return purgedFiles, purgedFolders, nil
		}
		if err := s.PurgeFileNode(ctx, dueFileIDs[i]); err != nil {
			if errors.Is(err, filemanager.ErrFileNotFound) || errors.Is(err, filemanager.ErrFileNotInTrash) {
				continue
			}
			return purgedFiles, purgedFolders, err
		}
		purgedFiles++
		ops++
	}

	for i := range dueFolders {
		if maxOps > 0 && ops >= maxOps {
			return purgedFiles, purgedFolders, nil
		}
		if err := s.PurgeFolderNode(ctx, dueFolders[i].ID); err != nil {
			if errors.Is(err, filemanager.ErrFolderNotFound) || errors.Is(err, filemanager.ErrFolderNotInTrash) {
				continue
			}
			return purgedFiles, purgedFolders, err
		}
		purgedFolders++
		ops++
	}

	return purgedFiles, purgedFolders, nil
}

func (s *FileManagerService) RestoreFile(ctx context.Context, id []byte, targetFolderID []byte, targetProvided bool, restoredBy []byte) error {
	row, err := s.fileRepo.GetByIDAny(ctx, id)
	if err != nil {
		return err
	}
	if row == nil {
		return filemanager.ErrFileNotFound
	}
	if row.DeletedAt == nil {
		return filemanager.ErrFileNotInTrash
	}

	_ = targetFolderID
	_ = targetProvided

	folder, err := s.folderRepo.GetByID(ctx, row.FolderID)
	if err != nil {
		return err
	}
	if folder == nil {
		return filemanager.ErrFolderNotFound
	}
	resolvedFolderID := append([]byte(nil), row.FolderID...)

	dup, err := s.fileRepo.GetByFolderAndName(ctx, resolvedFolderID, row.NameNormalized)
	if err != nil {
		return err
	}
	if dup != nil && !bytesEqual16(dup.ID, row.ID) {
		return filemanager.ErrDuplicateName
	}

	row.FolderID = append([]byte(nil), resolvedFolderID...)
	row.Status = filemanager.FileStatusReady
	row.TrashedAt = nil
	row.DeletedAt = nil
	row.DeletedBy = nil
	row.PurgeAt = nil
	row.NameSlot = "live"
	row.UpdatedBy = restoredBy

	if err := s.fileRepo.Update(ctx, row); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return filemanager.ErrDuplicateName
		}
		return err
	}
	if err := s.touchFolders(ctx, restoredBy, row.FolderID); err != nil {
		return err
	}
	row.UpdatedAt = time.Now().UTC()
	s.emitFileStatusRealtimeEvent(ctx, row, filemanager.FileStatusReady, restoredBy, "")
	return nil
}

func (s *FileManagerService) PurgeFile(ctx context.Context, id []byte) error {
	return s.fileRepo.Purge(ctx, id)
}

func (s *FileManagerService) GetFileByID(ctx context.Context, id []byte) (*filemanager.File, error) {
	return s.fileRepo.GetByID(ctx, id)
}

func (s *FileManagerService) GetFileByIDAny(ctx context.Context, id []byte) (*filemanager.File, error) {
	return s.fileRepo.GetByIDAny(ctx, id)
}

func (s *FileManagerService) GetFileByObjectKey(ctx context.Context, objectKey string) (*filemanager.File, error) {
	return s.fileRepo.GetByObjectKey(ctx, objectKey)
}

func (s *FileManagerService) GetFileByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
	return s.fileRepo.GetByFolderAndName(ctx, folderID, nameNormalized)
}

func (s *FileManagerService) GetFileByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) {
	return s.fileRepo.GetByTemplateAndTakeover(ctx, templateUUID, takeoverID)
}

func (s *FileManagerService) ListFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
	return s.fileRepo.ListByFolder(ctx, folderID, normalizeFileSort(sort), limit, offset)
}

func (s *FileManagerService) ListTrashFilesByFolder(ctx context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
	return s.fileRepo.ListTrashByFolder(ctx, folderID, normalizeFileSort(sort), limit, offset)
}

func (s *FileManagerService) CreateAttachment(ctx context.Context, params filemanager.CreateAttachmentParams) (*filemanager.Attachment, error) {
	if s.attachmentRepo == nil {
		return nil, filemanager.ErrAttachmentRepositoryDisabled
	}

	refType := strings.ToLower(strings.TrimSpace(params.RefType))
	refID := strings.TrimSpace(params.RefID)
	if refType == "" || refID == "" {
		return nil, filemanager.ErrAttachmentRefRequired
	}

	row, err := s.fileRepo.GetByID(ctx, params.FileID)
	if err != nil {
		return nil, err
	}
	if row == nil {
		return nil, filemanager.ErrFileNotFound
	}
	if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady {
		return nil, filemanager.ErrFileNotReadyForAttachment
	}

	category := normalizeOptionalString(params.Category)

	dup, err := s.attachmentRepo.GetByRefAndFile(ctx, refType, refID, row.ID)
	if err != nil {
		return nil, err
	}
	if dup != nil {
		return nil, filemanager.ErrAttachmentAlreadyExists
	}

	attachment := &filemanager.Attachment{
		FileID:     append([]byte(nil), row.ID...),
		RefType:    refType,
		RefID:      refID,
		Category:   category,
		SortOrder:  params.SortOrder,
		IsPrimary:  params.IsPrimary,
		AttachedBy: append([]byte(nil), params.ActorID...),
	}

	if err := s.attachmentRepo.Create(ctx, attachment); err != nil {
		if errors.Is(err, gorm.ErrDuplicatedKey) {
			return nil, filemanager.ErrAttachmentAlreadyExists
		}
		return nil, err
	}

	if err := s.MarkFileActive(ctx, row.ID, params.ActorID); err != nil {
		return nil, err
	}

	created, err := s.attachmentRepo.GetByID(ctx, attachment.ID)
	if err != nil {
		return nil, err
	}
	if created != nil {
		return created, nil
	}
	return attachment, nil
}

func (s *FileManagerService) GetAttachmentByID(ctx context.Context, id []byte) (*filemanager.Attachment, error) {
	if s.attachmentRepo == nil {
		return nil, filemanager.ErrAttachmentRepositoryDisabled
	}
	return s.attachmentRepo.GetByID(ctx, id)
}

func (s *FileManagerService) ListAttachmentsByReference(ctx context.Context, params filemanager.ListAttachmentsParams) ([]filemanager.Attachment, int64, error) {
	if s.attachmentRepo == nil {
		return nil, 0, filemanager.ErrAttachmentRepositoryDisabled
	}

	refType := strings.ToLower(strings.TrimSpace(params.RefType))
	refID := strings.TrimSpace(params.RefID)
	if refType == "" || refID == "" {
		return nil, 0, filemanager.ErrAttachmentRefRequired
	}

	filter := filemanager.AttachmentListFilter{
		RefType:  refType,
		RefID:    refID,
		Category: normalizeOptionalString(params.Category),
		Sort:     normalizeAttachmentSort(params.Sort),
		Limit:    params.Limit,
		Offset:   params.Offset,
	}
	return s.attachmentRepo.ListByReference(ctx, filter)
}

func (s *FileManagerService) DeleteAttachment(ctx context.Context, id []byte) error {
	if s.attachmentRepo == nil {
		return filemanager.ErrAttachmentRepositoryDisabled
	}

	existing, err := s.attachmentRepo.GetByID(ctx, id)
	if err != nil {
		return err
	}
	if existing == nil {
		return filemanager.ErrAttachmentNotFound
	}

	if err := s.attachmentRepo.Delete(ctx, id); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return filemanager.ErrAttachmentNotFound
		}
		return err
	}
	if err := s.ReconcileFileLifecycle(ctx, existing.FileID, nil); err != nil {
		return err
	}
	return nil
}

func (s *FileManagerService) ListEditedWBFilesByTakeoverID(ctx context.Context, takeoverID []byte) ([]filemanager.File, error) {
	return s.fileRepo.ListEditedTemplatesByTakeoverID(ctx, takeoverID)
}

func (s *FileManagerService) MarkFileActive(ctx context.Context, fileID []byte, actorID []byte) error {
	if len(fileID) == 0 {
		return nil
	}
	lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository)
	if !ok {
		return nil
	}
	row, err := s.fileRepo.GetByIDAny(ctx, fileID)
	if err != nil {
		return err
	}
	if row == nil || row.DeletedAt != nil {
		return nil
	}
	now := time.Now().UTC()
	if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
		ID:              fileID,
		Status:          filemanager.FileLifecycleStatusActive,
		UpdatedBy:       actorID,
		AttachedAt:      &now,
		ClearOrphanedAt: true,
	}); err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		return nil
	}
	s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagKeep)
	return nil
}

func (s *FileManagerService) ReconcileFileLifecycle(ctx context.Context, fileID []byte, actorID []byte) error {
	if len(fileID) == 0 {
		return nil
	}
	lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository)
	if !ok {
		return nil
	}
	row, err := s.fileRepo.GetByIDAny(ctx, fileID)
	if err != nil {
		return err
	}
	if row == nil || row.DeletedAt != nil {
		return nil
	}
	if filemanager.NormalizeFileLifecycleStatus(row.LifecycleStatus) == filemanager.FileLifecycleStatusDeleted {
		return nil
	}
	referenced, err := lifecycleRepo.IsFileReferenced(ctx, fileID)
	if err != nil {
		return err
	}
	if referenced {
		return s.MarkFileActive(ctx, fileID, actorID)
	}
	now := time.Now().UTC()
	if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
		ID:         fileID,
		Status:     filemanager.FileLifecycleStatusOrphanPending,
		UpdatedBy:  actorID,
		OrphanedAt: &now,
	}); err != nil {
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		return nil
	}
	s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagKeep)
	return nil
}

func (s *FileManagerService) CleanupOrphanPendingFiles(
	ctx context.Context,
	now time.Time,
	gracePeriod time.Duration,
	limit int,
	dryRun bool,
) (filemanager.FileCleanupStats, error) {
	stats := filemanager.FileCleanupStats{DryRun: dryRun}
	lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository)
	if !ok {
		return stats, nil
	}
	if limit <= 0 {
		limit = 100
	}
	cutoff := now.UTC()
	if gracePeriod > 0 {
		cutoff = cutoff.Add(-gracePeriod)
	}
	rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{
		Status:                  filemanager.FileLifecycleStatusOrphanPending,
		OrphanedBeforeEq:        &cutoff,
		ExcludeInTrash:          true,
		ExcludeLifecycleDeleted: true,
		Limit:                   limit,
	})
	if err != nil {
		return stats, err
	}
	stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun)
	s.logger.Info("file cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun)
	return stats, nil
}

func (s *FileManagerService) CleanupStaleTempUploads(
	ctx context.Context,
	now time.Time,
	gracePeriod time.Duration,
	limit int,
	dryRun bool,
) (filemanager.FileCleanupStats, error) {
	stats := filemanager.FileCleanupStats{DryRun: dryRun}
	lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository)
	if !ok {
		return stats, nil
	}
	if limit <= 0 {
		limit = 100
	}
	cutoff := now.UTC()
	if gracePeriod > 0 {
		cutoff = cutoff.Add(-gracePeriod)
	}
	rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{
		Status:                  filemanager.FileLifecycleStatusTemp,
		CreatedBeforeEq:         &cutoff,
		ExcludeInTrash:          true,
		ExcludeLifecycleDeleted: true,
		Limit:                   limit,
	})
	if err != nil {
		return stats, err
	}
	stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun)
	s.logger.Info("temp upload cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun)
	return stats, nil
}

func (s *FileManagerService) CleanupStaleDraftFiles(
	ctx context.Context,
	now time.Time,
	gracePeriod time.Duration,
	limit int,
	dryRun bool,
) (filemanager.FileCleanupStats, error) {
	stats := filemanager.FileCleanupStats{DryRun: dryRun}
	lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository)
	if !ok {
		return stats, nil
	}
	if limit <= 0 {
		limit = 100
	}
	cutoff := now.UTC()
	if gracePeriod > 0 {
		cutoff = cutoff.Add(-gracePeriod)
	}
	rows, err := lifecycleRepo.ListFilesByLifecycle(ctx, filemanager.FileLifecycleListFilter{
		Status:                  filemanager.FileLifecycleStatusDraft,
		CreatedBeforeEq:         &cutoff,
		ExcludeInTrash:          true,
		ExcludeLifecycleDeleted: true,
		Limit:                   limit,
	})
	if err != nil {
		return stats, err
	}
	stats = s.reapUnreferencedFiles(ctx, lifecycleRepo, rows, dryRun)
	s.logger.Info("draft cleanup completed", "scanned", stats.Scanned, "deleted", stats.Deleted, "skipped", stats.Skipped, "failed", stats.Failed, "dry_run", dryRun)
	return stats, nil
}

func (s *FileManagerService) reapUnreferencedFiles(
	ctx context.Context,
	lifecycleRepo filemanager.FileLifecycleRepository,
	rows []filemanager.File,
	dryRun bool,
) filemanager.FileCleanupStats {
	stats := filemanager.FileCleanupStats{DryRun: dryRun}
	for i := range rows {
		row := rows[i]
		stats.Scanned++

		referenced, refErr := lifecycleRepo.IsFileReferenced(ctx, row.ID)
		if refErr != nil {
			stats.Failed++
			s.logger.Error("file cleanup: reference check failed", "file_id", fileIDString(row.ID), "error", refErr)
			continue
		}
		if referenced {
			if err := s.MarkFileActive(ctx, row.ID, nil); err != nil {
				stats.Failed++
				s.logger.Error("file cleanup: re-activate failed", "file_id", fileIDString(row.ID), "error", err)
				continue
			}
			stats.Skipped++
			continue
		}

		if dryRun {
			stats.Skipped++
			s.logger.Info("file cleanup dry-run: candidate", "file_id", fileIDString(row.ID), "object_key", row.ObjectKey)
			continue
		}

		s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagDelete)
		if err := s.deleteFileObjects(ctx, &row); err != nil {
			stats.Failed++
			s.logger.Error("file cleanup: object delete failed", "file_id", fileIDString(row.ID), "error", err)
			continue
		}
		deletedAt := time.Now().UTC()
		if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
			ID:              row.ID,
			Status:          filemanager.FileLifecycleStatusDeleted,
			UpdatedBy:       nil,
			LifecycleDelete: &deletedAt,
			ClearOrphanedAt: true,
		}); err != nil {
			stats.Failed++
			s.logger.Error("file cleanup: mark deleted failed", "file_id", fileIDString(row.ID), "error", err)
			continue
		}
		stats.Deleted++
	}
	return stats
}

const (
	lifecycleTagKey    = "gc"
	lifecycleTagTemp   = "temp"
	lifecycleTagKeep   = "keep"
	lifecycleTagDelete = "delete"
)

func (s *FileManagerService) ensureLifecycleTempMarker(ctx context.Context, row *filemanager.File) {
	if row == nil {
		return
	}
	if row.DeletedAt != nil {
		return
	}
	status := filemanager.NormalizeFileLifecycleStatus(row.LifecycleStatus)
	if status != "" && status != filemanager.FileLifecycleStatusTemp {
		return
	}
	if lifecycleRepo, ok := s.fileRepo.(filemanager.FileLifecycleRepository); ok {
		if err := lifecycleRepo.UpdateLifecycle(ctx, filemanager.FileLifecycleTransitionParams{
			ID:              row.ID,
			Status:          filemanager.FileLifecycleStatusTemp,
			UpdatedBy:       row.UpdatedBy,
			ClearOrphanedAt: true,
		}); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			s.logger.Error("failed to set temp lifecycle status", "file_id", fileIDString(row.ID), "error", err)
		}
	}
	s.applyLifecycleTag(ctx, row.ObjectKey, lifecycleTagTemp)
}

func (s *FileManagerService) applyLifecycleTag(ctx context.Context, objectKey, value string) {
	if !s.enableS3Tagging {
		return
	}
	tagger, ok := s.storage.(filemanager.ObjectTaggingStorage)
	if !ok {
		return
	}
	key := strings.TrimSpace(objectKey)
	val := strings.TrimSpace(value)
	if key == "" || val == "" {
		return
	}
	go func(k, v string) {
		bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := tagger.PutObjectTagging(bgCtx, k, map[string]string{lifecycleTagKey: v}); err != nil {
			s.logger.Error("failed to apply lifecycle object tag", "object_key", k, "tag", lifecycleTagKey+"="+v, "error", err)
		}
	}(key, val)
}

func (s *FileManagerService) deleteFileObjects(ctx context.Context, row *filemanager.File) error {
	if row == nil || s.storage == nil {
		return nil
	}
	keys := make([]string, 0, 2)
	if key := strings.TrimSpace(row.ObjectKey); key != "" {
		keys = append(keys, key)
	}
	if row.ThumbnailObjectKey != nil {
		if key := strings.TrimSpace(*row.ThumbnailObjectKey); key != "" {
			keys = append(keys, key)
		}
	}
	for i := range keys {
		if err := s.storage.DeleteObject(ctx, keys[i]); err != nil {
			return err
		}
	}
	return nil
}

func fileIDString(id []byte) string {
	v, err := uuidv7.BytesToString(id)
	if err != nil {
		return ""
	}
	return v
}

func normalizeFolderName(raw string) (string, string, error) {
	name := strings.TrimSpace(raw)
	if name == "" {
		return "", "", filemanager.ErrInvalidName
	}
	if name == "." || name == ".." || strings.ContainsAny(name, "/\\") {
		return "", "", filemanager.ErrInvalidName
	}
	nameNormalized := strings.ToLower(name)
	if nameNormalized == filemanager.SystemRootFolderName {
		return "", "", filemanager.ErrReservedName
	}
	return name, nameNormalized, nil
}

func normalizeFileName(raw string) (string, string, error) {
	name := strings.TrimSpace(raw)
	if name == "" {
		return "", "", filemanager.ErrInvalidName
	}
	name = strings.ReplaceAll(name, "\\", "/")
	name = filepath.Base(name)
	if name == "" || name == "." || name == "/" || strings.Contains(name, "\x00") {
		return "", "", filemanager.ErrInvalidName
	}
	return name, strings.ToLower(name), nil
}

func buildFolderPathCache(parentPath, name string) string {
	parentPath = strings.TrimSpace(parentPath)
	if parentPath == "" {
		return "/" + name
	}
	parentPath = strings.TrimRight(parentPath, "/")
	return parentPath + "/" + name
}

func extensionFromName(name string) string {
	ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".")
	if len(ext) > 32 {
		ext = ext[:32]
	}
	return ext
}

func (s *FileManagerService) resolveAvailableFileName(
	ctx context.Context,
	folderID []byte,
	requestedName string,
	excludeID []byte,
) (string, string, error) {
	name, nameNormalized, err := normalizeFileName(requestedName)
	if err != nil {
		return "", "", err
	}

	available, err := s.isFileNameAvailable(ctx, folderID, nameNormalized, excludeID)
	if err != nil {
		return "", "", err
	}
	if available {
		return name, nameNormalized, nil
	}

	base, ext := splitNameAndExt(name)
	for i := 1; i <= 10000; i++ {
		candidateRaw := indexedFileName(base, ext, i)
		candidate, candidateNormalized, err := normalizeFileName(candidateRaw)
		if err != nil {
			return "", "", err
		}
		available, err := s.isFileNameAvailable(ctx, folderID, candidateNormalized, excludeID)
		if err != nil {
			return "", "", err
		}
		if available {
			return candidate, candidateNormalized, nil
		}
	}

	return "", "", filemanager.ErrDuplicateName
}

func (s *FileManagerService) isFileNameAvailable(
	ctx context.Context,
	folderID []byte,
	nameNormalized string,
	excludeID []byte,
) (bool, error) {
	dup, err := s.fileRepo.GetByFolderAndName(ctx, folderID, nameNormalized)
	if err != nil {
		return false, err
	}
	if dup == nil {
		return true, nil
	}
	if len(excludeID) == 16 && bytesEqual16(dup.ID, excludeID) {
		return true, nil
	}
	return false, nil
}

func splitNameAndExt(name string) (string, string) {
	ext := filepath.Ext(name)
	base := strings.TrimSuffix(name, ext)
	if base == "" {
		base = name
		ext = ""
	}
	return base, ext
}

func indexedFileName(base, ext string, idx int) string {
	suffix := fmt.Sprintf(" (%d)", idx)
	maxNameRunes := 255
	allowedBaseRunes := maxNameRunes - utf8.RuneCountInString(ext) - utf8.RuneCountInString(suffix)
	if allowedBaseRunes < 1 {
		allowedBaseRunes = 1
	}
	if utf8.RuneCountInString(base) > allowedBaseRunes {
		base = truncateRunes(base, allowedBaseRunes)
		base = strings.TrimSpace(base)
		if base == "" {
			base = "file"
		}
	}
	return base + suffix + ext
}

func truncateRunes(value string, maxRunes int) string {
	if maxRunes <= 0 {
		return ""
	}
	if utf8.RuneCountInString(value) <= maxRunes {
		return value
	}
	var b strings.Builder
	count := 0
	for _, r := range value {
		if count >= maxRunes {
			break
		}
		b.WriteRune(r)
		count++
	}
	return b.String()
}

func buildImmutableFileObjectKey(name string) string {
	uuidBytes := uuidv7.MustBytes()
	uuidStr, err := uuidv7.BytesToString(uuidBytes)
	if err != nil {
		uuidStr = strings.ReplaceAll(fmt.Sprintf("%x", uuidBytes), "-", "")
	}
	uuidStr = strings.ReplaceAll(uuidStr, "-", "")
	ext := filepath.Ext(name)
	ext = strings.ToLower(strings.TrimSpace(ext))
	if len(ext) > 16 {
		ext = ext[:16]
	}
	datePrefix := time.Now().UTC().Format("2006/01/02")
	if ext == "" {
		return "files/" + datePrefix + "/" + uuidStr
	}
	return "files/" + datePrefix + "/" + uuidStr + ext
}

func tempUploadNameSlot() string {
	uuidBytes := uuidv7.MustBytes()
	slot, err := uuidv7.BytesToString(uuidBytes)
	if err == nil && strings.TrimSpace(slot) != "" {
		return slot
	}
	return strings.ReplaceAll(fmt.Sprintf("%x", uuidBytes), "-", "")
}

func ensureFileStatusTransition(fromStatus, toStatus string) error {
	from := filemanager.NormalizeFileStatus(fromStatus)
	to := filemanager.NormalizeFileStatus(toStatus)
	if !filemanager.CanTransitionFileStatus(from, to) {
		return fmt.Errorf("%w: %s -> %s", filemanager.ErrInvalidFileStatusTransition, from, to)
	}
	return nil
}

func normalizeUploadPolicy(policy filemanager.FileUploadPolicy) filemanager.FileUploadPolicy {
	normalized := filemanager.FileUploadPolicy{
		MaxFileSizeBytes:  policy.MaxFileSizeBytes,
		AllowEmptyFile:    policy.AllowEmptyFile,
		MaxFilenameLength: policy.MaxFilenameLength,
	}
	if normalized.MaxFilenameLength <= 0 {
		normalized.MaxFilenameLength = 255
	}

	if len(policy.AllowedMimeTypes) > 0 {
		seen := make(map[string]struct{}, len(policy.AllowedMimeTypes))
		for i := range policy.AllowedMimeTypes {
			v := normalizeMimeTypeToken(policy.AllowedMimeTypes[i])
			if v == "" {
				continue
			}
			if _, ok := seen[v]; ok {
				continue
			}
			seen[v] = struct{}{}
			normalized.AllowedMimeTypes = append(normalized.AllowedMimeTypes, v)
		}
	}

	if len(policy.AllowedExtensions) > 0 {
		seen := make(map[string]struct{}, len(policy.AllowedExtensions))
		for i := range policy.AllowedExtensions {
			v := normalizeExtensionToken(policy.AllowedExtensions[i])
			if v == "" {
				continue
			}
			if _, ok := seen[v]; ok {
				continue
			}
			seen[v] = struct{}{}
			normalized.AllowedExtensions = append(normalized.AllowedExtensions, v)
		}
	}

	return normalized
}

func (s *FileManagerService) validateUploadAgainstPolicy(fileName, contentType string, sizeBytes int64) error {
	policy := s.uploadPolicy

	if sizeBytes == 0 && !policy.AllowEmptyFile {
		return filemanager.ErrEmptyFileNotAllowed
	}
	if policy.MaxFileSizeBytes > 0 && sizeBytes > policy.MaxFileSizeBytes {
		return fmt.Errorf("%w: max %d bytes", filemanager.ErrFileTooLarge, policy.MaxFileSizeBytes)
	}

	if policy.MaxFilenameLength > 0 {
		nameLength := utf8.RuneCountInString(fileName)
		if nameLength > policy.MaxFilenameLength {
			return fmt.Errorf("%w: max %d characters", filemanager.ErrFileNameTooLong, policy.MaxFilenameLength)
		}
	}

	if len(policy.AllowedMimeTypes) > 0 {
		allowed := false
		normalizedMime := normalizeMimeTypeToken(contentType)
		for i := range policy.AllowedMimeTypes {
			if policy.AllowedMimeTypes[i] == normalizedMime {
				allowed = true
				break
			}
		}
		if !allowed {
			return fmt.Errorf("%w: %s", filemanager.ErrFileMimeTypeNotAllowed, normalizedMime)
		}
	}

	if len(policy.AllowedExtensions) > 0 {
		allowed := false
		ext := normalizeExtensionToken(extensionFromName(fileName))
		for i := range policy.AllowedExtensions {
			if policy.AllowedExtensions[i] == ext {
				allowed = true
				break
			}
		}
		if !allowed {
			if ext == "" {
				return fmt.Errorf("%w: file has no extension", filemanager.ErrFileExtensionNotAllowed)
			}
			return fmt.Errorf("%w: .%s", filemanager.ErrFileExtensionNotAllowed, ext)
		}
	}

	return nil
}

func normalizeUploadedMimeType(raw string) string {
	normalized := normalizeMimeTypeToken(raw)
	if normalized == "" {
		return "application/octet-stream"
	}
	return normalized
}

func normalizeMimeTypeToken(raw string) string {
	v := strings.ToLower(strings.TrimSpace(raw))
	if idx := strings.Index(v, ";"); idx >= 0 {
		v = strings.TrimSpace(v[:idx])
	}
	return v
}

func normalizeExtensionToken(raw string) string {
	v := strings.ToLower(strings.TrimSpace(raw))
	v = strings.TrimPrefix(v, ".")
	return v
}

func (s *FileManagerService) fileOutboxReady() bool {
	return s.fileOutbox != nil && s.fileOutboxSerializer != nil
}

func (s *FileManagerService) persistFileProcessingOutbox(
	ctx context.Context,
	outbox filemanager.FileProcessingOutboxWriter,
	row *filemanager.File,
	actorID []byte,
) error {
	if outbox == nil || !s.fileOutboxReady() {
		return filemanager.ErrFileProcessingQueueDisabled
	}
	job, err := buildFileProcessingJob(row, actorID)
	if err != nil {
		return err
	}
	message, err := s.fileOutboxSerializer.Serialize(ctx, queue.FileProcessingJob{
		JobID:      job.JobID,
		FileUUID:   job.FileUUID,
		Bucket:     job.Bucket,
		ObjectKey:  job.ObjectKey,
		UploadedBy: job.UploadedBy,
		MimeType:   job.MimeType,
		SizeBytes:  job.SizeBytes,
		TraceID:    job.TraceID,
		RequestID:  job.RequestID,
	})
	if err != nil {
		return 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 err
	}
	return outbox.CreateFileProcessingOutboxMessage(ctx, record)
}

func (s *FileManagerService) enqueueUploadedFileForProcessing(ctx context.Context, row *filemanager.File, actorID []byte) error {
	if row == nil {
		return filemanager.ErrFileNotFound
	}
	if s.fileQueue == nil {
		return filemanager.ErrFileProcessingQueueDisabled
	}
	job, err := buildFileProcessingJob(row, actorID)
	if err != nil {
		return err
	}
	return s.fileQueue.Enqueue(ctx, job)
}

func (s *FileManagerService) verifyUploadedObjectForIntent(ctx context.Context, intent *filemanager.FileUploadIntent) (filemanager.ObjectMetadata, error) {
	if intent == nil {
		return filemanager.ObjectMetadata{}, filemanager.ErrUploadIntentNotFound
	}

	meta, err := s.storage.HeadObject(ctx, intent.ObjectKey)
	if err != nil {
		if errors.Is(err, filemanager.ErrUploadObjectNotFound) {
			return filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound
		}
		return filemanager.ObjectMetadata{}, err
	}

	if strings.TrimSpace(meta.ObjectKey) == "" {
		meta.ObjectKey = strings.TrimSpace(intent.ObjectKey)
	}
	if strings.TrimSpace(meta.Bucket) == "" {
		meta.Bucket = strings.TrimSpace(intent.Bucket)
	}

	if strings.TrimSpace(meta.ObjectKey) != strings.TrimSpace(intent.ObjectKey) {
		return filemanager.ObjectMetadata{}, fmt.Errorf("%w: object key mismatch", filemanager.ErrUploadObjectMismatch)
	}
	if expectedBucket := strings.TrimSpace(intent.Bucket); expectedBucket != "" && !strings.EqualFold(strings.TrimSpace(meta.Bucket), expectedBucket) {
		return filemanager.ObjectMetadata{}, fmt.Errorf("%w: bucket mismatch", filemanager.ErrUploadObjectMismatch)
	}
	if intent.SizeBytes >= 0 && meta.SizeBytes != intent.SizeBytes {
		return filemanager.ObjectMetadata{}, fmt.Errorf("%w: expected size %d bytes, got %d bytes", filemanager.ErrUploadObjectMismatch, intent.SizeBytes, meta.SizeBytes)
	}

	expectedMime := normalizeMimeTypeToken(intent.MimeType)
	actualMime := normalizeMimeTypeToken(meta.ContentType)
	if expectedMime != "" && actualMime != "" && expectedMime != actualMime {
		return filemanager.ObjectMetadata{}, fmt.Errorf("%w: expected mime %s, got %s", filemanager.ErrUploadObjectMismatch, expectedMime, actualMime)
	}
	return meta, nil
}

func (s *FileManagerService) createUploadedFileFromIntent(
	ctx context.Context,
	intent *filemanager.FileUploadIntent,
	meta filemanager.ObjectMetadata,
	actorID []byte,
) (*filemanager.File, error) {
	if intent == nil {
		return nil, filemanager.ErrUploadIntentNotFound
	}

	root, err := s.GetOrCreateSystemRootFolder(ctx)
	if err != nil {
		return nil, err
	}

	sizeBytes := intent.SizeBytes
	if meta.SizeBytes >= 0 {
		sizeBytes = meta.SizeBytes
	}
	mimeType := normalizeUploadedMimeType(firstNonEmptyString(meta.ContentType, intent.MimeType))
	row := &filemanager.File{
		ID:                  uuidv7.MustBytes(),
		FolderID:            root.ID,
		Name:                intent.Name,
		NameNormalized:      intent.NameNormalized,
		Extension:           firstNonEmptyString(intent.Extension, extensionFromName(intent.Name)),
		SizeBytes:           sizeBytes,
		MimeType:            mimeType,
		Bucket:              firstNonEmptyString(meta.Bucket, intent.Bucket),
		ObjectKey:           firstNonEmptyString(meta.ObjectKey, intent.ObjectKey),
		ObjectVersion:       strings.TrimSpace(meta.VersionID),
		ETag:                strings.TrimSpace(meta.ETag),
		IsTemplate:          false,
		Status:              filemanager.FileStatusUploaded,
		LifecycleStatus:     filemanager.FileLifecycleStatusTemp,
		ProcessingStartedAt: nil,
		ProcessedAt:         nil,
		ValidatedAt:         nil,
		NameSlot:            tempUploadNameSlot(),
		CreatedBy:           actorID,
		UpdatedBy:           actorID,
	}

	if err := s.fileRepo.Create(ctx, row); err != nil {
		return nil, err
	}

	created, err := s.fileRepo.GetByID(ctx, row.ID)
	if err == nil && created != nil {
		return created, nil
	}
	return row, nil
}

func (s *FileManagerService) completeFileUploadIntentDurable(
	ctx context.Context,
	txRepo filemanager.FileTransactionalRepository,
	intent *filemanager.FileUploadIntent,
	meta filemanager.ObjectMetadata,
	actorID []byte,
) (*filemanager.File, error) {
	var completedRow *filemanager.File
	root, err := s.GetOrCreateSystemRootFolder(ctx)
	if err != nil {
		return nil, err
	}

	if err := txRepo.WithTransaction(ctx, func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error {
		intentRepo, ok := repo.(filemanager.FileUploadIntentRepository)
		if !ok {
			return filemanager.ErrUploadIntentRepositoryDisabled
		}

		lockedIntent, err := intentRepo.GetUploadIntentByIDForUpdate(ctx, intent.ID)
		if err != nil {
			return err
		}
		if lockedIntent == nil {
			return filemanager.ErrUploadIntentNotFound
		}

		status := filemanager.NormalizeFileUploadIntentStatus(lockedIntent.Status)
		switch status {
		case filemanager.FileUploadIntentStatusCompleted:
			existing, getErr := repo.GetByObjectKey(ctx, lockedIntent.ObjectKey)
			if getErr != nil {
				return getErr
			}
			if existing != nil {
				completedRow = existing
				return nil
			}
			return filemanager.ErrUploadIntentAlreadyComplete
		case filemanager.FileUploadIntentStatusExpired:
			return filemanager.ErrUploadIntentExpired
		case filemanager.FileUploadIntentStatusPending:
		default:
			return filemanager.ErrUploadIntentStatusConflict
		}

		now := time.Now().UTC()
		if !lockedIntent.ExpiresAt.IsZero() && now.After(lockedIntent.ExpiresAt.UTC()) {
			if err := intentRepo.MarkUploadIntentExpired(ctx, filemanager.FileUploadIntentExpireParams{
				ID:        lockedIntent.ID,
				UpdatedBy: actorID,
				ExpiredAt: now,
			}); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return err
			}
			return filemanager.ErrUploadIntentExpired
		}

		existing, err := repo.GetByObjectKey(ctx, lockedIntent.ObjectKey)
		if err != nil {
			return err
		}
		if existing != nil {
			completedRow = existing
			if err := intentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{
				ID:          lockedIntent.ID,
				UpdatedBy:   actorID,
				CompletedAt: now,
			}); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return err
			}
			return nil
		}

		sizeBytes := lockedIntent.SizeBytes
		if meta.SizeBytes >= 0 {
			sizeBytes = meta.SizeBytes
		}
		row := &filemanager.File{
			ID:                  uuidv7.MustBytes(),
			FolderID:            root.ID,
			Name:                lockedIntent.Name,
			NameNormalized:      lockedIntent.NameNormalized,
			Extension:           firstNonEmptyString(lockedIntent.Extension, extensionFromName(lockedIntent.Name)),
			SizeBytes:           sizeBytes,
			MimeType:            normalizeUploadedMimeType(firstNonEmptyString(meta.ContentType, lockedIntent.MimeType)),
			Bucket:              firstNonEmptyString(meta.Bucket, lockedIntent.Bucket),
			ObjectKey:           firstNonEmptyString(meta.ObjectKey, lockedIntent.ObjectKey),
			ObjectVersion:       strings.TrimSpace(meta.VersionID),
			ETag:                strings.TrimSpace(meta.ETag),
			Status:              filemanager.FileStatusUploaded,
			LifecycleStatus:     filemanager.FileLifecycleStatusTemp,
			ProcessingStartedAt: nil,
			ProcessedAt:         nil,
			ValidatedAt:         nil,
			NameSlot:            tempUploadNameSlot(),
			CreatedBy:           actorID,
			UpdatedBy:           actorID,
		}

		if err := repo.Create(ctx, row); err != nil {
			return err
		}
		if err := s.persistFileProcessingOutbox(ctx, outbox, row, actorID); err != nil {
			return err
		}
		if err := intentRepo.MarkUploadIntentCompleted(ctx, filemanager.FileUploadIntentCompleteParams{
			ID:          lockedIntent.ID,
			UpdatedBy:   actorID,
			CompletedAt: now,
		}); err != nil {
			return err
		}

		completedRow = row
		return nil
	}); err != nil {
		return nil, err
	}

	if completedRow == nil {
		return nil, filemanager.ErrFileNotFound
	}

	if len(completedRow.ID) == 16 {
		created, err := s.fileRepo.GetByID(ctx, completedRow.ID)
		if err == nil && created != nil {
			return created, nil
		}
	}
	return completedRow, nil
}

func (s *FileManagerService) emitFileStatusRealtimeEvent(
	ctx context.Context,
	row *filemanager.File,
	status string,
	actorID []byte,
	failureReason string,
) {
	if s.fileStatusEvents == nil || row == nil {
		return
	}
	if len(row.ID) == 0 {
		return
	}

	status = filemanager.NormalizeFileStatus(status)
	if !filemanager.IsKnownFileStatus(status) {
		return
	}

	userID := pickRealtimeEventUserID(actorID, row.CreatedBy, row.UpdatedBy)
	if len(userID) == 0 {
		return
	}

	name := strings.TrimSpace(row.Name)
	reason := strings.TrimSpace(failureReason)
	if reason == "" {
		reason = strings.TrimSpace(row.FailureReason)
	}
	occurredAt := row.UpdatedAt
	if occurredAt.IsZero() {
		occurredAt = time.Now().UTC()
	}

	_ = s.fileStatusEvents.CreateFileStatusRealtimeEvent(ctx, filemanager.FileStatusRealtimeEventInput{
		FileID:        append([]byte(nil), row.ID...),
		UserID:        userID,
		Status:        status,
		Name:          name,
		FailureReason: reason,
		OccurredAt:    occurredAt,
	})
}

func pickRealtimeEventUserID(candidates ...[]byte) []byte {
	for i := range candidates {
		if len(candidates[i]) == 16 {
			return append([]byte(nil), candidates[i]...)
		}
	}
	return nil
}

func buildFileProcessingJob(row *filemanager.File, actorID []byte) (filemanager.FileProcessingJob, error) {
	if row == nil {
		return filemanager.FileProcessingJob{}, filemanager.ErrFileNotFound
	}
	job := filemanager.FileProcessingJob{
		Bucket:     strings.TrimSpace(row.Bucket),
		ObjectKey:  strings.TrimSpace(row.ObjectKey),
		MimeType:   strings.TrimSpace(row.MimeType),
		SizeBytes:  row.SizeBytes,
		UploadedBy: bytesToUUIDString(actorID),
	}
	fileUUID, err := uuidv7.BytesToString(row.ID)
	if err != nil {
		return filemanager.FileProcessingJob{}, fmt.Errorf("convert file id to uuid: %w", err)
	}
	job.FileUUID = fileUUID
	return job, nil
}

func truncateFailureReason(raw string) string {
	const maxReasonLength = 1024
	raw = strings.TrimSpace(raw)
	if len(raw) <= maxReasonLength {
		return raw
	}
	return raw[:maxReasonLength]
}

func bytesToUUIDString(raw []byte) string {
	if len(raw) == 0 {
		return ""
	}
	value, err := uuidv7.BytesToString(raw)
	if err != nil {
		return ""
	}
	return value
}

func firstNonEmptyString(values ...string) string {
	for i := range values {
		value := strings.TrimSpace(values[i])
		if value != "" {
			return value
		}
	}
	return ""
}

func cloneTrimmedStringMap(input map[string]string) map[string]string {
	if len(input) == 0 {
		return nil
	}
	out := make(map[string]string, len(input))
	for key, value := range input {
		k := strings.TrimSpace(key)
		if k == "" {
			continue
		}
		out[k] = strings.TrimSpace(value)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func (s *FileManagerService) collectFolderSubtreeAny(ctx context.Context, rootID []byte) ([]filemanager.Folder, error) {
	root, err := s.folderRepo.GetByIDAny(ctx, rootID)
	if err != nil {
		return nil, err
	}
	if root == nil {
		return nil, nil
	}

	out := make([]filemanager.Folder, 0, 16)
	queue := make([]filemanager.Folder, 0, 16)
	seen := make(map[string]struct{}, 16)

	rootUUID := bytesToUUIDString(root.ID)
	seen[rootUUID] = struct{}{}
	out = append(out, *root)
	queue = append(queue, *root)

	for len(queue) > 0 {
		parent := queue[0]
		queue = queue[1:]

		activeChildren, _, err := s.folderRepo.ListByParent(ctx, parent.ID, "name ASC", 0, 0)
		if err != nil {
			return nil, err
		}
		trashedChildren, _, err := s.folderRepo.ListTrashByParent(ctx, parent.ID, "name ASC", 0, 0)
		if err != nil {
			return nil, err
		}

		for i := range activeChildren {
			child := activeChildren[i]
			key := bytesToUUIDString(child.ID)
			if _, ok := seen[key]; ok {
				continue
			}
			seen[key] = struct{}{}
			out = append(out, child)
			queue = append(queue, child)
		}
		for i := range trashedChildren {
			child := trashedChildren[i]
			key := bytesToUUIDString(child.ID)
			if _, ok := seen[key]; ok {
				continue
			}
			seen[key] = struct{}{}
			out = append(out, child)
			queue = append(queue, child)
		}
	}

	return out, nil
}

func (s *FileManagerService) listActiveFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) {
	out := make([]filemanager.File, 0, len(folderIDs))
	for i := range folderIDs {
		rows, _, err := s.fileRepo.ListByFolder(ctx, folderIDs[i], "name ASC", 0, 0)
		if err != nil {
			return nil, err
		}
		out = append(out, rows...)
	}
	return out, nil
}

func (s *FileManagerService) listTrashedFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) {
	out := make([]filemanager.File, 0, len(folderIDs))
	for i := range folderIDs {
		rows, _, err := s.fileRepo.ListTrashByFolder(ctx, folderIDs[i], "name ASC", 0, 0)
		if err != nil {
			return nil, err
		}
		out = append(out, rows...)
	}
	return out, nil
}

func (s *FileManagerService) listAllFilesByFolders(ctx context.Context, folderIDs [][]byte) ([]filemanager.File, error) {
	activeRows, err := s.listActiveFilesByFolders(ctx, folderIDs)
	if err != nil {
		return nil, err
	}
	trashedRows, err := s.listTrashedFilesByFolders(ctx, folderIDs)
	if err != nil {
		return nil, err
	}

	out := make([]filemanager.File, 0, len(activeRows)+len(trashedRows))
	seen := make(map[string]struct{}, len(activeRows)+len(trashedRows))

	appendUnique := func(rows []filemanager.File) {
		for i := range rows {
			key := bytesToUUIDString(rows[i].ID)
			if _, ok := seen[key]; ok {
				continue
			}
			seen[key] = struct{}{}
			out = append(out, rows[i])
		}
	}

	appendUnique(activeRows)
	appendUnique(trashedRows)
	return out, nil
}

func uniqueNonEmptyStrings(values []string) []string {
	if len(values) == 0 {
		return nil
	}

	out := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for i := range values {
		value := strings.TrimSpace(values[i])
		if value == "" {
			continue
		}
		if _, ok := seen[value]; ok {
			continue
		}
		seen[value] = struct{}{}
		out = append(out, value)
	}
	return out
}

func replaceFolderPathPrefix(pathCache string, oldPrefix string, newPrefix string) string {
	if oldPrefix == "" {
		return pathCache
	}
	if pathCache == oldPrefix {
		return newPrefix
	}
	prefixWithSlash := oldPrefix + "/"
	if strings.HasPrefix(pathCache, prefixWithSlash) {
		return newPrefix + strings.TrimPrefix(pathCache, oldPrefix)
	}
	return pathCache
}

func bytesEqual16(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

func isSystemRootFolder(row *filemanager.Folder) bool {
	if row == nil {
		return false
	}
	return len(row.ParentID) == 0 && strings.EqualFold(strings.TrimSpace(row.NameNormalized), filemanager.SystemRootFolderName)
}

func normalizeFolderSort(sort string) string {
	if normalized := normalizeSortByRules(sort,
		sortField("name"),
		sortField("depth"),
		sortField("created_at"),
		sortField("updated_at", "updated_at", "updated_by"),
	); normalized != "" {
		return normalized
	}
	return "name ASC"
}

func normalizeFileSort(sort string) string {
	if normalized := normalizeSortByRules(sort,
		sortField("name"),
		sortField("size_bytes"),
		sortField("mime_type"),
		sortField("created_at"),
		sortField("updated_at", "updated_at", "updated_by"),
	); normalized != "" {
		return normalized
	}
	return "name ASC"
}

func normalizeAttachmentSort(sort string) string {
	if normalized := normalizeSortByRules(sort,
		sortExpr("sort_order ASC, created_at DESC", "sort_order DESC, created_at DESC", "sort_order"),
		sortField("created_at"),
		sortField("updated_at", "updated_at", "updated_by"),
	); normalized != "" {
		return normalized
	}
	return "sort_order ASC, created_at DESC"
}

func normalizeOptionalString(value *string) *string {
	if value == nil {
		return nil
	}
	cleaned := strings.TrimSpace(*value)
	if cleaned == "" {
		return nil
	}
	return &cleaned
}

func (s *FileManagerService) touchFolders(ctx context.Context, updatedBy []byte, folderIDs ...[]byte) error {
	seen := make(map[string]struct{}, len(folderIDs))
	now := time.Now().UTC()
	queue := make([][]byte, 0, len(folderIDs))
	for i := range folderIDs {
		if len(folderIDs[i]) != 0 {
			queue = append(queue, append([]byte(nil), folderIDs[i]...))
		}
	}
	for len(queue) > 0 {
		id := queue[0]
		queue = queue[1:]
		if len(id) == 0 {
			continue
		}
		key := string(id)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}

		folder, err := s.folderRepo.GetByIDAny(ctx, id)
		if err != nil {
			return err
		}
		if folder == nil {
			continue
		}
		if len(folder.ParentID) != 0 {
			queue = append(queue, append([]byte(nil), folder.ParentID...))
		}
		if folder.DeletedAt != nil {
			continue
		}
		folder.UpdatedBy = append([]byte(nil), updatedBy...)
		folder.UpdatedAt = now
		if err := s.folderRepo.Update(ctx, folder); err != nil {
			return err
		}
	}
	return nil
}
