package service

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

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

type fileManagerFolderRepoMock struct {
	createCalled            bool
	updateCalled            bool
	updateCalls             int
	moveSubtreeCalled       bool
	deleteCalled            bool
	restoreCalled           bool
	purgeCalled             bool
	getByIDCalled           bool
	getByIDAnyCalled        bool
	getByParentNameCalled   bool
	listByParentCalled      bool
	listByParentCalls       int
	listTrashCalled         bool
	listTrashByParentCalled bool

	lastCreate                *filemanager.Folder
	lastUpdate                *filemanager.Folder
	updateIDs                 [][]byte
	lastMoveSubtreeID         []byte
	lastMoveTargetParentID    []byte
	lastMoveTargetName        string
	lastMoveTargetNameNorm    string
	lastMoveUpdatedBy         []byte
	lastDeleteID              []byte
	lastDeleteBy              []byte
	lastDeletePurgeAt         *time.Time
	lastRestoreID             []byte
	lastRestoreBy             []byte
	lastPurgeID               []byte
	lastGetByID               []byte
	lastGetByIDAny            []byte
	lastGetParentID           []byte
	lastGetNameNormalized     string
	lastListParentID          []byte
	lastListSort              string
	lastListLimit             int
	lastListOffset            int
	lastListTrashParentID     []byte
	lastListTrashSortGlobal   string
	lastListTrashLimitGlobal  int
	lastListTrashOffsetGlobal int
	lastListTrashSort         string
	lastListTrashLimit        int
	lastListTrashOffset       int
	getByIDResult             *filemanager.Folder
	getByIDAnyResult          *filemanager.Folder
	getByParentNameResult     *filemanager.Folder
	listByParentResult        []filemanager.Folder
	listByParentTotal         int64
	listTrashByParentResult   []filemanager.Folder
	listTrashResult           []filemanager.Folder
	listTrashByParentTotal    int64
	listTrashTotal            int64
	moveSubtreeResult         *filemanager.Folder
	createErr                 error
	updateErr                 error
	moveSubtreeErr            error
	deleteErr                 error
	restoreErr                error
	purgeErr                  error
	getByIDErr                error
	getByIDAnyErr             error
	getByParentNameErr        error
	listByParentErr           error
	listTrashByParentErr      error
	listTrashErr              error
	listByParentFn            func(context.Context, []byte, string, int, int) ([]filemanager.Folder, int64, error)
}

func (m *fileManagerFolderRepoMock) Create(_ context.Context, row *filemanager.Folder) error {
	m.createCalled = true
	m.lastCreate = row
	return m.createErr
}

func (m *fileManagerFolderRepoMock) Update(_ context.Context, row *filemanager.Folder) error {
	m.updateCalled = true
	m.updateCalls++
	m.lastUpdate = row
	if row != nil {
		m.updateIDs = append(m.updateIDs, append([]byte(nil), row.ID...))
	}
	return m.updateErr
}

func (m *fileManagerFolderRepoMock) MoveSubtree(_ context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*filemanager.Folder, error) {
	m.moveSubtreeCalled = true
	m.lastMoveSubtreeID = id
	m.lastMoveTargetParentID = targetParentID
	m.lastMoveTargetName = targetName
	m.lastMoveTargetNameNorm = targetNameNormalized
	m.lastMoveUpdatedBy = updatedBy
	return m.moveSubtreeResult, m.moveSubtreeErr
}

func (m *fileManagerFolderRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	m.lastDeletePurgeAt = purgeAt
	return m.deleteErr
}

func (m *fileManagerFolderRepoMock) Restore(_ context.Context, id []byte, restoredBy []byte) error {
	m.restoreCalled = true
	m.lastRestoreID = id
	m.lastRestoreBy = restoredBy
	return m.restoreErr
}

func (m *fileManagerFolderRepoMock) Purge(_ context.Context, id []byte) error {
	m.purgeCalled = true
	m.lastPurgeID = id
	return m.purgeErr
}
func (m *fileManagerFolderRepoMock) SearchByName(context.Context, string, string, int, int) ([]filemanager.Folder, int64, error) {
	return nil, 0, nil
}

func (m *fileManagerFolderRepoMock) GetByID(_ context.Context, id []byte) (*filemanager.Folder, error) {
	m.getByIDCalled = true
	m.lastGetByID = id
	return m.getByIDResult, m.getByIDErr
}

func (m *fileManagerFolderRepoMock) GetByIDAny(_ context.Context, id []byte) (*filemanager.Folder, error) {
	m.getByIDAnyCalled = true
	m.lastGetByIDAny = id
	return m.getByIDAnyResult, m.getByIDAnyErr
}

func (m *fileManagerFolderRepoMock) GetByParentAndName(_ context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) {
	m.getByParentNameCalled = true
	m.lastGetParentID = parentID
	m.lastGetNameNormalized = nameNormalized
	return m.getByParentNameResult, m.getByParentNameErr
}

func (m *fileManagerFolderRepoMock) ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
	m.listByParentCalled = true
	m.listByParentCalls++
	m.lastListParentID = parentID
	m.lastListSort = sort
	m.lastListLimit = limit
	m.lastListOffset = offset
	if m.listByParentFn != nil {
		return m.listByParentFn(ctx, parentID, sort, limit, offset)
	}
	return m.listByParentResult, m.listByParentTotal, m.listByParentErr
}

func (m *fileManagerFolderRepoMock) ListTrashByParent(_ context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
	m.listTrashByParentCalled = true
	m.lastListTrashParentID = parentID
	m.lastListTrashSort = sort
	m.lastListTrashLimit = limit
	m.lastListTrashOffset = offset
	return m.listTrashByParentResult, m.listTrashByParentTotal, m.listTrashByParentErr
}

func (m *fileManagerFolderRepoMock) ListTrash(_ context.Context, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
	m.listTrashCalled = true
	m.lastListTrashSortGlobal = sort
	m.lastListTrashLimitGlobal = limit
	m.lastListTrashOffsetGlobal = offset
	return m.listTrashResult, m.listTrashTotal, m.listTrashErr
}

type fileManagerFileRepoMock struct {
	createCalled                bool
	updateCalled                bool
	transitionStatusCalled      bool
	transitionStatusCalls       int
	withTransactionCalled       bool
	finalizeCalled              bool
	deleteCalled                bool
	restoreCalled               bool
	purgeCalled                 bool
	getByIDCalled               bool
	getByIDAnyCalled            bool
	getByObjectKeyCalled        bool
	getByFolderNameCalled       bool
	getByTemplateTakeoverCalled bool
	listByFolderCalled          bool
	listTrashByFolderCalled     bool
	updateLifecycleCalled       bool
	isFileReferencedCalled      bool
	listByLifecycleCalled       bool

	lastCreate               *filemanager.File
	lastUpdate               *filemanager.File
	lastTransitionStatus     filemanager.FileStatusTransitionParams
	lastFinalize             filemanager.FileFinalizeParams
	lastDeleteID             []byte
	lastDeleteBy             []byte
	lastDeletePurgeAt        *time.Time
	lastRestoreID            []byte
	lastRestoreBy            []byte
	lastPurgeID              []byte
	lastGetByID              []byte
	lastGetByIDAny           []byte
	lastGetByObjectKey       string
	lastGetFolderID          []byte
	lastGetNameNormalized    string
	lastListFolderID         []byte
	lastListSort             string
	lastListLimit            int
	lastListOffset           int
	lastTrashListFolderID    []byte
	lastTrashListSort        string
	lastTrashListLimit       int
	lastTrashListOffset      int
	getByIDResult            *filemanager.File
	getByIDAnyResult         *filemanager.File
	getByObjectKeyResult     *filemanager.File
	getByFolderNameResult    *filemanager.File
	listByFolderResult       []filemanager.File
	listByFolderTotal        int64
	listTrashByFolderResult  []filemanager.File
	listTrashByFolderTotal   int64
	listByLifecycleResult    []filemanager.File
	outboxMessages           []*filemanager.FileProcessingOutboxMessage
	realtimeEvents           []filemanager.FileStatusRealtimeEventInput
	createErr                error
	updateErr                error
	transitionStatusErr      error
	withTransactionErr       error
	outboxErr                error
	realtimeEventErr         error
	finalizeErr              error
	deleteErr                error
	restoreErr               error
	purgeErr                 error
	getByIDErr               error
	getByIDAnyErr            error
	getByObjectKeyErr        error
	getByFolderNameErr       error
	getByTemplateTakeoverErr error
	listByFolderErr          error
	listTrashByFolderErr     error
	updateLifecycleErr       error
	isFileReferencedErr      error
	listByLifecycleErr       error
	isFileReferencedResult   bool

	createFn                func(context.Context, *filemanager.File) error
	updateFn                func(context.Context, *filemanager.File) error
	transitionStatusFn      func(context.Context, filemanager.FileStatusTransitionParams) error
	finalizeFn              func(context.Context, filemanager.FileFinalizeParams) error
	getByIDFn               func(context.Context, []byte) (*filemanager.File, error)
	getByFolderNameFn       func(context.Context, []byte, string) (*filemanager.File, error)
	getByTemplateTakeoverFn func(context.Context, []byte, []byte) (*filemanager.File, error)
	listByLifecycleFn       func(context.Context, filemanager.FileLifecycleListFilter) ([]filemanager.File, error)
	isReferencedFn          func(context.Context, []byte) (bool, error)
	uploadIntentByID        map[string]*filemanager.FileUploadIntent
}

func (m *fileManagerFileRepoMock) WithTransaction(ctx context.Context, fn func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error) error {
	m.withTransactionCalled = true
	if m.withTransactionErr != nil {
		return m.withTransactionErr
	}
	if fn == nil {
		return nil
	}
	return fn(m, m)
}

func (m *fileManagerFileRepoMock) CreateFileProcessingOutboxMessage(_ context.Context, message *filemanager.FileProcessingOutboxMessage) error {
	if m.outboxErr != nil {
		return m.outboxErr
	}
	if message == nil {
		return errors.New("nil outbox message")
	}
	cp := *message
	cp.ID = append([]byte(nil), message.ID...)
	cp.Body = append([]byte(nil), message.Body...)
	cp.Attributes = append([]byte(nil), message.Attributes...)
	m.outboxMessages = append(m.outboxMessages, &cp)
	return nil
}

func (m *fileManagerFileRepoMock) CreateFileStatusRealtimeEvent(_ context.Context, input filemanager.FileStatusRealtimeEventInput) error {
	if m.realtimeEventErr != nil {
		return m.realtimeEventErr
	}
	cp := input
	cp.FileID = append([]byte(nil), input.FileID...)
	cp.UserID = append([]byte(nil), input.UserID...)
	m.realtimeEvents = append(m.realtimeEvents, cp)
	return nil
}

func (m *fileManagerFileRepoMock) Create(ctx context.Context, row *filemanager.File) error {
	m.createCalled = true
	if row != nil && len(row.ID) == 0 {
		row.ID = uuidv7.MustBytes()
	}
	m.lastCreate = row
	if m.createFn != nil {
		return m.createFn(ctx, row)
	}
	return m.createErr
}

func (m *fileManagerFileRepoMock) Update(ctx context.Context, row *filemanager.File) error {
	m.updateCalled = true
	m.lastUpdate = row
	if m.updateFn != nil {
		return m.updateFn(ctx, row)
	}
	return m.updateErr
}

func (m *fileManagerFileRepoMock) TransitionStatus(ctx context.Context, params filemanager.FileStatusTransitionParams) error {
	m.transitionStatusCalled = true
	m.transitionStatusCalls++
	m.lastTransitionStatus = params
	if m.transitionStatusFn != nil {
		return m.transitionStatusFn(ctx, params)
	}
	return m.transitionStatusErr
}

func (m *fileManagerFileRepoMock) Finalize(ctx context.Context, params filemanager.FileFinalizeParams) error {
	m.finalizeCalled = true
	m.lastFinalize = params
	if m.finalizeFn != nil {
		return m.finalizeFn(ctx, params)
	}
	return m.finalizeErr
}

func (m *fileManagerFileRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	m.lastDeletePurgeAt = purgeAt
	return m.deleteErr
}

func (m *fileManagerFileRepoMock) Restore(_ context.Context, id []byte, restoredBy []byte) error {
	m.restoreCalled = true
	m.lastRestoreID = id
	m.lastRestoreBy = restoredBy
	return m.restoreErr
}

func (m *fileManagerFileRepoMock) Purge(_ context.Context, id []byte) error {
	m.purgeCalled = true
	m.lastPurgeID = id
	return m.purgeErr
}
func (m *fileManagerFileRepoMock) SearchByName(context.Context, string, string, int, int) ([]filemanager.File, int64, error) {
	return nil, 0, nil
}

func (m *fileManagerFileRepoMock) GetByID(ctx context.Context, id []byte) (*filemanager.File, error) {
	m.getByIDCalled = true
	m.lastGetByID = id
	if m.getByIDFn != nil {
		return m.getByIDFn(ctx, id)
	}
	return m.getByIDResult, m.getByIDErr
}

func (m *fileManagerFileRepoMock) GetByIDAny(_ context.Context, id []byte) (*filemanager.File, error) {
	m.getByIDAnyCalled = true
	m.lastGetByIDAny = id
	return m.getByIDAnyResult, m.getByIDAnyErr
}

func (m *fileManagerFileRepoMock) GetByObjectKey(_ context.Context, objectKey string) (*filemanager.File, error) {
	m.getByObjectKeyCalled = true
	m.lastGetByObjectKey = objectKey
	return m.getByObjectKeyResult, m.getByObjectKeyErr
}

func (m *fileManagerFileRepoMock) GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
	m.getByFolderNameCalled = true
	m.lastGetFolderID = folderID
	m.lastGetNameNormalized = nameNormalized
	if m.getByFolderNameFn != nil {
		return m.getByFolderNameFn(ctx, folderID, nameNormalized)
	}
	return m.getByFolderNameResult, m.getByFolderNameErr
}

func (m *fileManagerFileRepoMock) GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) {
	m.getByTemplateTakeoverCalled = true
	if m.getByTemplateTakeoverFn != nil {
		return m.getByTemplateTakeoverFn(ctx, templateUUID, takeoverID)
	}
	return nil, m.getByTemplateTakeoverErr
}

func (m *fileManagerFileRepoMock) ListByFolder(_ context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
	m.listByFolderCalled = true
	m.lastListFolderID = folderID
	m.lastListSort = sort
	m.lastListLimit = limit
	m.lastListOffset = offset
	return m.listByFolderResult, m.listByFolderTotal, m.listByFolderErr
}

func (m *fileManagerFileRepoMock) ListTrashByFolder(_ context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
	m.listTrashByFolderCalled = true
	m.lastTrashListFolderID = folderID
	m.lastTrashListSort = sort
	m.lastTrashListLimit = limit
	m.lastTrashListOffset = offset
	return m.listTrashByFolderResult, m.listTrashByFolderTotal, m.listTrashByFolderErr
}

func (m *fileManagerFileRepoMock) UpdateLifecycle(_ context.Context, params filemanager.FileLifecycleTransitionParams) error {
	m.updateLifecycleCalled = true
	if m.updateLifecycleErr != nil {
		return m.updateLifecycleErr
	}
	if m.getByIDAnyResult != nil && string(m.getByIDAnyResult.ID) == string(params.ID) {
		m.getByIDAnyResult.LifecycleStatus = params.Status
		m.getByIDAnyResult.AttachedAt = params.AttachedAt
		if params.ClearOrphanedAt {
			m.getByIDAnyResult.OrphanedAt = nil
		} else {
			m.getByIDAnyResult.OrphanedAt = params.OrphanedAt
		}
		m.getByIDAnyResult.LifecycleDeletedAt = params.LifecycleDelete
	}
	return nil
}

func (m *fileManagerFileRepoMock) IsFileReferenced(ctx context.Context, fileID []byte) (bool, error) {
	m.isFileReferencedCalled = true
	if m.isReferencedFn != nil {
		return m.isReferencedFn(ctx, fileID)
	}
	if m.isFileReferencedErr != nil {
		return false, m.isFileReferencedErr
	}
	return m.isFileReferencedResult, nil
}

func (m *fileManagerFileRepoMock) ListFilesByLifecycle(ctx context.Context, filter filemanager.FileLifecycleListFilter) ([]filemanager.File, error) {
	m.listByLifecycleCalled = true
	if m.listByLifecycleFn != nil {
		return m.listByLifecycleFn(ctx, filter)
	}
	if m.listByLifecycleErr != nil {
		return nil, m.listByLifecycleErr
	}
	return m.listByLifecycleResult, nil
}

func (m *fileManagerFileRepoMock) CreateUploadIntent(_ context.Context, row *filemanager.FileUploadIntent) error {
	if row == nil {
		return errors.New("upload intent is required")
	}
	cp := *row
	cp.ID = append([]byte(nil), row.ID...)
	cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
	cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
	if m.uploadIntentByID == nil {
		m.uploadIntentByID = map[string]*filemanager.FileUploadIntent{}
	}
	m.uploadIntentByID[string(cp.ID)] = &cp
	return nil
}

func (m *fileManagerFileRepoMock) GetUploadIntentByID(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
	if m.uploadIntentByID == nil {
		return nil, nil
	}
	row, ok := m.uploadIntentByID[string(id)]
	if !ok || row == nil {
		return nil, nil
	}
	cp := *row
	cp.ID = append([]byte(nil), row.ID...)
	cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
	cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
	return &cp, nil
}

func (m *fileManagerFileRepoMock) GetUploadIntentByIDForUpdate(ctx context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
	return m.GetUploadIntentByID(ctx, id)
}

func (m *fileManagerFileRepoMock) MarkUploadIntentCompleted(_ context.Context, params filemanager.FileUploadIntentCompleteParams) error {
	if m.uploadIntentByID == nil {
		return nil
	}
	row, ok := m.uploadIntentByID[string(params.ID)]
	if !ok || row == nil {
		return nil
	}
	completedAt := params.CompletedAt
	row.Status = filemanager.FileUploadIntentStatusCompleted
	row.CompletedAt = &completedAt
	row.UpdatedBy = append([]byte(nil), params.UpdatedBy...)
	return nil
}

func (m *fileManagerFileRepoMock) MarkUploadIntentExpired(_ context.Context, params filemanager.FileUploadIntentExpireParams) error {
	if m.uploadIntentByID == nil {
		return nil
	}
	row, ok := m.uploadIntentByID[string(params.ID)]
	if !ok || row == nil {
		return nil
	}
	row.Status = filemanager.FileUploadIntentStatusExpired
	row.UpdatedBy = append([]byte(nil), params.UpdatedBy...)
	return nil
}

type fileManagerStorageMock struct {
	putCalls        int
	deleteCalls     int
	deletedKeys     []string
	presignPutCalls int
	putErrAt        map[int]error
	lastInput       filemanager.PutObjectInput
	headMeta        filemanager.ObjectMetadata
	headErr         error
	presignPut      filemanager.PresignedPutObject
	presignErr      error
	taggingCalls    int
	lastTags        map[string]string
	taggingErr      error
}

type fileProcessingQueueMock struct {
	enqueueCalled bool
	lastJob       filemanager.FileProcessingJob
	enqueueErr    error
}

type fileUploadIntentRepoMock struct {
	createCalled            bool
	getByIDCalled           bool
	getByIDForUpdateCalled  bool
	markCompletedCalled     bool
	markExpiredCalled       bool
	lastCreate              *filemanager.FileUploadIntent
	lastGetID               []byte
	lastGetForUpdateID      []byte
	lastMarkCompletedParams filemanager.FileUploadIntentCompleteParams
	lastMarkExpiredParams   filemanager.FileUploadIntentExpireParams
	createErr               error
	getByIDErr              error
	getByIDForUpdateErr     error
	markCompletedErr        error
	markExpiredErr          error
	getByIDResult           *filemanager.FileUploadIntent
	getByIDForUpdateResult  *filemanager.FileUploadIntent
	storedByID              map[string]*filemanager.FileUploadIntent
}

func (m *fileUploadIntentRepoMock) CreateUploadIntent(_ context.Context, row *filemanager.FileUploadIntent) error {
	m.createCalled = true
	if row != nil {
		cp := *row
		cp.ID = append([]byte(nil), row.ID...)
		cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
		cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
		m.lastCreate = &cp
		if m.storedByID == nil {
			m.storedByID = map[string]*filemanager.FileUploadIntent{}
		}
		m.storedByID[string(cp.ID)] = &cp
	}
	return m.createErr
}

func (m *fileUploadIntentRepoMock) GetUploadIntentByID(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
	m.getByIDCalled = true
	m.lastGetID = append([]byte(nil), id...)
	if m.getByIDErr != nil {
		return nil, m.getByIDErr
	}
	if m.getByIDResult != nil {
		cp := *m.getByIDResult
		cp.ID = append([]byte(nil), m.getByIDResult.ID...)
		return &cp, nil
	}
	if m.storedByID != nil {
		if row, ok := m.storedByID[string(id)]; ok {
			cp := *row
			cp.ID = append([]byte(nil), row.ID...)
			return &cp, nil
		}
	}
	return nil, nil
}

func (m *fileUploadIntentRepoMock) GetUploadIntentByIDForUpdate(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
	m.getByIDForUpdateCalled = true
	m.lastGetForUpdateID = append([]byte(nil), id...)
	if m.getByIDForUpdateErr != nil {
		return nil, m.getByIDForUpdateErr
	}
	if m.getByIDForUpdateResult != nil {
		cp := *m.getByIDForUpdateResult
		cp.ID = append([]byte(nil), m.getByIDForUpdateResult.ID...)
		return &cp, nil
	}
	if m.storedByID != nil {
		if row, ok := m.storedByID[string(id)]; ok {
			cp := *row
			cp.ID = append([]byte(nil), row.ID...)
			return &cp, nil
		}
	}
	return nil, nil
}

func (m *fileUploadIntentRepoMock) MarkUploadIntentCompleted(_ context.Context, params filemanager.FileUploadIntentCompleteParams) error {
	m.markCompletedCalled = true
	m.lastMarkCompletedParams = params
	if m.markCompletedErr != nil {
		return m.markCompletedErr
	}
	if m.storedByID != nil {
		if row, ok := m.storedByID[string(params.ID)]; ok {
			row.Status = filemanager.FileUploadIntentStatusCompleted
			completedAt := params.CompletedAt
			row.CompletedAt = &completedAt
		}
	}
	return nil
}

func (m *fileUploadIntentRepoMock) MarkUploadIntentExpired(_ context.Context, params filemanager.FileUploadIntentExpireParams) error {
	m.markExpiredCalled = true
	m.lastMarkExpiredParams = params
	if m.markExpiredErr != nil {
		return m.markExpiredErr
	}
	if m.storedByID != nil {
		if row, ok := m.storedByID[string(params.ID)]; ok {
			row.Status = filemanager.FileUploadIntentStatusExpired
		}
	}
	return nil
}

func (m *fileProcessingQueueMock) Enqueue(_ context.Context, job filemanager.FileProcessingJob) error {
	m.enqueueCalled = true
	m.lastJob = job
	return m.enqueueErr
}

func (m *fileManagerStorageMock) EnsureBucket(_ context.Context) error { return nil }

func (m *fileManagerStorageMock) PutObject(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
	m.putCalls++
	m.lastInput = input
	if m.putErrAt != nil {
		if err, ok := m.putErrAt[m.putCalls]; ok && err != nil {
			return filemanager.ObjectMetadata{}, err
		}
	}
	return filemanager.ObjectMetadata{
		Bucket:    "test-bucket",
		ObjectKey: input.ObjectKey,
		ETag:      "etag",
		VersionID: "v1",
		SizeBytes: input.SizeBytes,
	}, nil
}

func (m *fileManagerStorageMock) DeleteObject(_ context.Context, key string) error {
	m.deleteCalls++
	m.deletedKeys = append(m.deletedKeys, key)
	return nil
}

func (m *fileManagerStorageMock) HeadObject(_ context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
	if m.headErr != nil {
		return filemanager.ObjectMetadata{}, m.headErr
	}
	if strings.TrimSpace(m.headMeta.ObjectKey) != "" {
		return m.headMeta, nil
	}
	return filemanager.ObjectMetadata{
		Bucket:    "test-bucket",
		ObjectKey: objectKey,
		SizeBytes: m.lastInput.SizeBytes,
	}, nil
}

func (m *fileManagerStorageMock) PresignPutObject(_ context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
	m.presignPutCalls++
	if m.presignErr != nil {
		return filemanager.PresignedPutObject{}, m.presignErr
	}
	if strings.TrimSpace(m.presignPut.URL) != "" {
		return m.presignPut, nil
	}
	return filemanager.PresignedPutObject{
		Bucket:    "test-bucket",
		ObjectKey: input.ObjectKey,
		Method:    "PUT",
		URL:       "https://example.local/upload",
		Headers:   map[string]string{"content-type": input.ContentType},
		ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
	}, nil
}

func (m *fileManagerStorageMock) PresignGetObject(_ context.Context, _ string, _ time.Duration) (string, error) {
	return "https://example.local/presigned", nil
}

func (m *fileManagerStorageMock) PutObjectTagging(_ context.Context, _ string, tags map[string]string) error {
	m.taggingCalls++
	m.lastTags = tags
	return m.taggingErr
}

func (m *fileManagerStorageMock) DeleteObjectTaggingKeys(_ context.Context, _ string, _ []string) error {
	return nil
}

func TestNewFileManagerService(t *testing.T) {
	folderRepo := &fileManagerFolderRepoMock{}
	fileRepo := &fileManagerFileRepoMock{}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	if svc == nil || svc.folderRepo == nil || svc.fileRepo == nil {
		t.Fatalf("expected service initialized")
	}
}

func TestFileManagerServiceFolderMethods(t *testing.T) {
	folderRepo := &fileManagerFolderRepoMock{
		getByIDResult:           &filemanager.Folder{Name: "Folder A"},
		getByIDAnyResult:        &filemanager.Folder{Name: "Folder Any"},
		getByParentNameResult:   &filemanager.Folder{Name: "Folder B"},
		listByParentResult:      []filemanager.Folder{{Name: "Folder A"}},
		listByParentTotal:       1,
		listTrashByParentResult: []filemanager.Folder{{Name: "Folder Trash"}},
		listTrashByParentTotal:  1,
	}
	fileRepo := &fileManagerFileRepoMock{}
	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})

	row := &filemanager.Folder{Name: "Create"}
	if err := svc.CreateFolder(context.Background(), row); err != nil {
		t.Fatalf("create folder: %v", err)
	}
	if !folderRepo.createCalled || folderRepo.lastCreate != row {
		t.Fatalf("expected CreateFolder forwarded")
	}

	if err := svc.UpdateFolder(context.Background(), row); err != nil {
		t.Fatalf("update folder: %v", err)
	}
	if !folderRepo.updateCalled || folderRepo.lastUpdate != row {
		t.Fatalf("expected UpdateFolder forwarded")
	}

	id := []byte("folder-id")
	actor := []byte("actor-id")
	purgeAt := time.Now().UTC().Add(24 * time.Hour)
	folderRepo.listByParentTotal = 0
	fileRepo.listByFolderTotal = 0
	if err := svc.DeleteFolder(context.Background(), id, actor, &purgeAt); err != nil {
		t.Fatalf("delete folder: %v", err)
	}
	if !folderRepo.deleteCalled {
		t.Fatalf("expected DeleteFolder forwarded")
	}

	deletedAt := time.Now().UTC()
	folderRepo.getByIDAnyResult = &filemanager.Folder{
		ID:        id,
		Name:      "Folder Any",
		PathCache: "/Folder Any",
		DeletedAt: &deletedAt,
	}
	folderRepo.getByParentNameResult = nil
	folderRepo.updateCalled = false
	if err := svc.RestoreFolder(context.Background(), id, nil, false, actor); err != nil {
		t.Fatalf("restore folder: %v", err)
	}
	if !folderRepo.updateCalled {
		t.Fatalf("expected RestoreFolder to update folder state")
	}
	folderRepo.listByParentTotal = 1
	fileRepo.listByFolderTotal = 1

	if err := svc.PurgeFolder(context.Background(), id); err != nil {
		t.Fatalf("purge folder: %v", err)
	}
	if !folderRepo.purgeCalled {
		t.Fatalf("expected PurgeFolder forwarded")
	}

	gotByID, err := svc.GetFolderByID(context.Background(), id)
	if err != nil {
		t.Fatalf("get folder by id: %v", err)
	}
	if gotByID == nil || gotByID.Name != "Folder A" {
		t.Fatalf("unexpected folder by id result")
	}

	gotByIDAny, err := svc.GetFolderByIDAny(context.Background(), id)
	if err != nil {
		t.Fatalf("get folder by id any: %v", err)
	}
	if gotByIDAny == nil || gotByIDAny.Name != "Folder Any" {
		t.Fatalf("unexpected folder by id any result")
	}

	folderRepo.getByParentNameResult = &filemanager.Folder{Name: "Folder B"}
	gotByName, err := svc.GetFolderByParentAndName(context.Background(), nil, "folder-b")
	if err != nil {
		t.Fatalf("get folder by parent/name: %v", err)
	}
	if gotByName == nil || gotByName.Name != "Folder B" {
		t.Fatalf("unexpected folder by parent/name result")
	}

	rows, total, err := svc.ListFoldersByParent(context.Background(), nil, "-name", 10, 0)
	if err != nil {
		t.Fatalf("list folders: %v", err)
	}
	if total != 1 || len(rows) != 1 {
		t.Fatalf("unexpected list folders result")
	}
	if folderRepo.lastListSort != "name DESC" {
		t.Fatalf("expected normalized folder sort, got %q", folderRepo.lastListSort)
	}

	trashRows, trashTotal, err := svc.ListTrashFoldersByParent(context.Background(), nil, "name", 10, 0)
	if err != nil {
		t.Fatalf("list trash folders: %v", err)
	}
	if trashTotal != 1 || len(trashRows) != 1 {
		t.Fatalf("unexpected list trash folders result")
	}
	if folderRepo.lastListTrashSort != "name ASC" {
		t.Fatalf("expected normalized trash folder sort, got %q", folderRepo.lastListTrashSort)
	}

	folderRepo.createErr = errors.New("create failed")
	if err := svc.CreateFolder(context.Background(), row); err == nil {
		t.Fatalf("expected create error")
	}
}

func TestFileManagerServiceFileMethods(t *testing.T) {
	folderID := []byte("folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByIDAnyResult: &filemanager.Folder{ID: append([]byte(nil), folderID...)},
	}
	fileRepo := &fileManagerFileRepoMock{
		getByIDResult:           &filemanager.File{Name: "File A"},
		getByIDAnyResult:        &filemanager.File{Name: "File Any", Status: filemanager.FileStatusReady},
		getByObjectKeyResult:    &filemanager.File{Name: "File B"},
		getByFolderNameResult:   &filemanager.File{Name: "File C"},
		listByFolderResult:      []filemanager.File{{Name: "File A"}},
		listByFolderTotal:       1,
		listTrashByFolderResult: []filemanager.File{{Name: "File Trash"}},
		listTrashByFolderTotal:  1,
	}
	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})

	row := &filemanager.File{Name: "Create.txt", FolderID: append([]byte(nil), folderID...), CreatedBy: []byte("actor-id")}
	if err := svc.CreateFile(context.Background(), row); err != nil {
		t.Fatalf("create file: %v", err)
	}
	if !fileRepo.createCalled || fileRepo.lastCreate != row {
		t.Fatalf("expected CreateFile forwarded")
	}
	if !folderRepo.updateCalled {
		t.Fatalf("expected CreateFile to touch parent folder")
	}

	if err := svc.UpdateFile(context.Background(), row); err != nil {
		t.Fatalf("update file: %v", err)
	}
	if !fileRepo.updateCalled || fileRepo.lastUpdate != row {
		t.Fatalf("expected UpdateFile forwarded")
	}

	id := []byte("file-id")
	actor := []byte("actor-id")
	purgeAt := time.Now().UTC().Add(24 * time.Hour)
	if err := svc.DeleteFile(context.Background(), id, actor, &purgeAt); err != nil {
		t.Fatalf("delete file: %v", err)
	}
	if !fileRepo.deleteCalled {
		t.Fatalf("expected DeleteFile forwarded")
	}

	fileDeletedAt := time.Now().UTC()
	fileRepo.getByIDAnyResult = &filemanager.File{
		ID:        id,
		Name:      "File Any",
		FolderID:  []byte("folder-id"),
		Status:    filemanager.FileStatusTrashed,
		DeletedAt: &fileDeletedAt,
	}
	fileRepo.getByFolderNameResult = nil
	fileRepo.updateCalled = false
	folderRepo.getByIDResult = &filemanager.Folder{Name: "Folder A"}
	if err := svc.RestoreFile(context.Background(), id, nil, false, actor); err != nil {
		t.Fatalf("restore file: %v", err)
	}
	if !fileRepo.updateCalled {
		t.Fatalf("expected RestoreFile to update file state")
	}

	if err := svc.PurgeFile(context.Background(), id); err != nil {
		t.Fatalf("purge file: %v", err)
	}
	if !fileRepo.purgeCalled {
		t.Fatalf("expected PurgeFile forwarded")
	}

	gotByID, err := svc.GetFileByID(context.Background(), id)
	if err != nil {
		t.Fatalf("get file by id: %v", err)
	}
	if gotByID == nil || gotByID.Name != "File A" {
		t.Fatalf("unexpected file by id result")
	}

	gotByIDAny, err := svc.GetFileByIDAny(context.Background(), id)
	if err != nil {
		t.Fatalf("get file by id any: %v", err)
	}
	if gotByIDAny == nil || gotByIDAny.Name != "File Any" {
		t.Fatalf("unexpected file by id any result")
	}

	gotByObjectKey, err := svc.GetFileByObjectKey(context.Background(), "fm/objects/x")
	if err != nil {
		t.Fatalf("get file by object key: %v", err)
	}
	if gotByObjectKey == nil || gotByObjectKey.Name != "File B" {
		t.Fatalf("unexpected file by object key result")
	}

	fileRepo.getByFolderNameResult = &filemanager.File{Name: "File C"}
	gotByName, err := svc.GetFileByFolderAndName(context.Background(), []byte("folder-id"), "file-c")
	if err != nil {
		t.Fatalf("get file by folder/name: %v", err)
	}
	if gotByName == nil || gotByName.Name != "File C" {
		t.Fatalf("unexpected file by folder/name result")
	}

	rows, total, err := svc.ListFilesByFolder(context.Background(), []byte("folder-id"), "-size_bytes", 10, 0)
	if err != nil {
		t.Fatalf("list files: %v", err)
	}
	if total != 1 || len(rows) != 1 {
		t.Fatalf("unexpected list files result")
	}
	if fileRepo.lastListSort != "size_bytes DESC" {
		t.Fatalf("expected normalized file sort, got %q", fileRepo.lastListSort)
	}

	trashRows, trashTotal, err := svc.ListTrashFilesByFolder(context.Background(), []byte("folder-id"), "-created_at", 10, 0)
	if err != nil {
		t.Fatalf("list trash files: %v", err)
	}
	if trashTotal != 1 || len(trashRows) != 1 {
		t.Fatalf("unexpected list trash files result")
	}
	if fileRepo.lastTrashListSort != "created_at DESC" {
		t.Fatalf("expected normalized trash file sort, got %q", fileRepo.lastTrashListSort)
	}

	fileRepo.createErr = errors.New("create failed")
	if err := svc.CreateFile(context.Background(), row); err == nil {
		t.Fatalf("expected create error")
	}
}

func TestFileManagerServiceListTrashNodes(t *testing.T) {
	folderRepo := &fileManagerFolderRepoMock{
		listTrashResult: []filemanager.Folder{{Name: "Folder Trash"}},
		listTrashTotal:  1,
	}
	fileRepo := &fileManagerFileRepoMock{
		listTrashByFolderResult: []filemanager.File{{Name: "File Trash"}},
		listTrashByFolderTotal:  1,
	}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	folders, files, err := svc.ListTrashNodes(context.Background())
	if err != nil {
		t.Fatalf("list trash nodes: %v", err)
	}
	if len(folders) != 1 || len(files) != 1 {
		t.Fatalf("unexpected trash node counts: folders=%d files=%d", len(folders), len(files))
	}
	if !folderRepo.listTrashCalled {
		t.Fatalf("expected folder ListTrash to be called")
	}
	if folderRepo.lastListTrashSortGlobal != "deleted_at DESC" {
		t.Fatalf("expected folder trash sort deleted_at DESC, got %q", folderRepo.lastListTrashSortGlobal)
	}
	if !fileRepo.listTrashByFolderCalled {
		t.Fatalf("expected file ListTrashByFolder to be called")
	}
	if len(fileRepo.lastTrashListFolderID) != 0 {
		t.Fatalf("expected file trash folder filter to be empty")
	}
	if fileRepo.lastTrashListSort != "deleted_at DESC" {
		t.Fatalf("expected file trash sort deleted_at DESC, got %q", fileRepo.lastTrashListSort)
	}
}

func TestFileManagerServiceListNodesByParent_RootIncludesSystemRootChildren(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             append([]byte(nil), rootID...),
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
		listByParentFn: func(_ context.Context, parentID []byte, _ string, _, _ int) ([]filemanager.Folder, int64, error) {
			if len(parentID) == 0 {
				rows := []filemanager.Folder{
					{ID: append([]byte(nil), rootID...), Name: filemanager.SystemRootFolderName, NameNormalized: filemanager.SystemRootFolderName},
					{ID: []byte("public-folder-id"), Name: "Public", NameNormalized: "public"},
				}
				return rows, int64(len(rows)), nil
			}
			if string(parentID) == string(rootID) {
				rows := []filemanager.Folder{
					{ID: []byte("aircraft-folder-id"), Name: "aircraft", NameNormalized: "aircraft"},
				}
				return rows, int64(len(rows)), nil
			}
			return nil, 0, nil
		},
	}
	fileRepo := &fileManagerFileRepoMock{
		listByFolderResult: []filemanager.File{{ID: []byte("file-1"), Name: "image.jpg"}},
		listByFolderTotal:  1,
	}
	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})

	folders, files, err := svc.ListNodesByParent(context.Background(), nil)
	if err != nil {
		t.Fatalf("list root nodes: %v", err)
	}
	if len(files) != 1 {
		t.Fatalf("expected 1 file at root, got %d", len(files))
	}
	if len(folders) != 2 {
		t.Fatalf("expected 2 folders (public + aircraft), got %d", len(folders))
	}
	if folderRepo.listByParentCalls != 2 {
		t.Fatalf("expected ListByParent called twice for root listing, got %d", folderRepo.listByParentCalls)
	}
	if got := string(fileRepo.lastListFolderID); got != string(rootID) {
		t.Fatalf("expected root folder id for file list, got %q", got)
	}
	if folders[0].Name != "aircraft" || folders[1].Name != "Public" {
		t.Fatalf("expected root folders sorted and include aircraft/public, got %#v", folders)
	}
}

func TestFileManagerServiceUploadFileNodesBulk_Success(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{}
	storage := &fileManagerStorageMock{}
	fileQueue := &fileProcessingQueueMock{}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(fileQueue)
	svc.SetFileStatusRealtimeEventWriter(fileRepo)

	results := svc.UploadFileNodesBulk(context.Background(), []filemanager.UploadFileParams{
		{Name: "invoice-1.pdf", ContentType: "application/pdf", SizeBytes: 12, Body: strings.NewReader("file-1")},
		{Name: "invoice-2.pdf", ContentType: "application/pdf", SizeBytes: 12, Body: strings.NewReader("file-2")},
	})

	if len(results) != 2 {
		t.Fatalf("expected 2 results, got %d", len(results))
	}
	for i := range results {
		if results[i].Err != nil {
			t.Fatalf("expected success at index %d, got error %v", i, results[i].Err)
		}
		if results[i].File == nil {
			t.Fatalf("expected file at index %d", i)
		}
		if results[i].File.Status != filemanager.FileStatusUploaded {
			t.Fatalf("expected uploaded status at index %d, got %s", i, results[i].File.Status)
		}
	}
	if storage.putCalls != 2 {
		t.Fatalf("expected 2 storage put calls, got %d", storage.putCalls)
	}
	if !fileQueue.enqueueCalled {
		t.Fatalf("expected enqueue called")
	}
}

func TestFileManagerServiceUploadFileNodesBulk_PartialFailure(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{}
	storage := &fileManagerStorageMock{
		putErrAt: map[int]error{
			2: errors.New("storage offline"),
		},
	}
	fileQueue := &fileProcessingQueueMock{}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(fileQueue)

	results := svc.UploadFileNodesBulk(context.Background(), []filemanager.UploadFileParams{
		{Name: "ok.pdf", ContentType: "application/pdf", SizeBytes: 2, Body: strings.NewReader("ok")},
		{Name: "fail.pdf", ContentType: "application/pdf", SizeBytes: 4, Body: strings.NewReader("fail")},
	})

	if len(results) != 2 {
		t.Fatalf("expected 2 results, got %d", len(results))
	}
	if results[0].Err != nil || results[0].File == nil {
		t.Fatalf("expected first upload success, got err=%v", results[0].Err)
	}
	if results[1].Err == nil {
		t.Fatalf("expected second upload failure")
	}
}

func TestFileManagerServiceCreateFileNodesFromUploadBulk_Success(t *testing.T) {
	files := map[string]*filemanager.File{
		"upload-1": {
			ID:             []byte("upload-1"),
			FolderID:       []byte("root-folder"),
			Name:           "raw-1.pdf",
			NameNormalized: "raw-1.pdf",
			Status:         filemanager.FileStatusValidated,
			NameSlot:       "tmp-slot-1",
		},
		"upload-2": {
			ID:             []byte("upload-2"),
			FolderID:       []byte("root-folder"),
			Name:           "raw-2.pdf",
			NameNormalized: "raw-2.pdf",
			Status:         filemanager.FileStatusValidated,
			NameSlot:       "tmp-slot-2",
		},
	}
	fileRepo := &fileManagerFileRepoMock{
		getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
			row := files[string(id)]
			if row == nil {
				return nil, nil
			}
			cp := *row
			return &cp, nil
		},
		getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
			for _, row := range files {
				if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
					cp := *row
					return &cp, nil
				}
			}
			return nil, nil
		},
		finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
			row := files[string(params.ID)]
			if row == nil {
				return nil
			}
			row.FolderID = append([]byte(nil), params.FolderID...)
			row.Name = params.Name
			row.NameNormalized = params.NameNormalized
			row.Extension = params.Extension
			row.Status = filemanager.FileStatusReady
			row.NameSlot = "live"
			return nil
		},
	}

	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
	nameOverride := "final-2.pdf"
	results := svc.CreateFileNodesFromUploadBulk(context.Background(), []filemanager.CreateFileFromUploadParams{
		{UploadFileID: []byte("upload-1")},
		{UploadFileID: []byte("upload-2"), Name: &nameOverride},
	})

	if len(results) != 2 {
		t.Fatalf("expected 2 results, got %d", len(results))
	}
	for i := range results {
		if results[i].Err != nil {
			t.Fatalf("expected success at index %d, got %v", i, results[i].Err)
		}
		if results[i].File == nil {
			t.Fatalf("expected file at index %d", i)
		}
		if results[i].File.Status != filemanager.FileStatusReady {
			t.Fatalf("expected ready status at index %d, got %s", i, results[i].File.Status)
		}
	}
	if files["upload-1"].NameSlot != "live" || files["upload-2"].NameSlot != "live" {
		t.Fatalf("expected finalized live name slots")
	}
}

func TestFileManagerServiceCreateFileNodesFromUploadBulk_PartialFailure(t *testing.T) {
	files := map[string]*filemanager.File{
		"upload-1": {
			ID:             []byte("upload-1"),
			FolderID:       []byte("root-folder"),
			Name:           "raw.pdf",
			NameNormalized: "raw.pdf",
			Status:         filemanager.FileStatusValidated,
			NameSlot:       "tmp-slot",
		},
	}
	fileRepo := &fileManagerFileRepoMock{
		getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
			row := files[string(id)]
			if row == nil {
				return nil, nil
			}
			cp := *row
			return &cp, nil
		},
		getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
			for _, row := range files {
				if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
					cp := *row
					return &cp, nil
				}
			}
			return nil, nil
		},
		finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
			row := files[string(params.ID)]
			if row == nil {
				return nil
			}
			row.FolderID = append([]byte(nil), params.FolderID...)
			row.Name = params.Name
			row.NameNormalized = params.NameNormalized
			row.Extension = params.Extension
			row.Status = filemanager.FileStatusReady
			row.NameSlot = "live"
			return nil
		},
	}

	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
	results := svc.CreateFileNodesFromUploadBulk(context.Background(), []filemanager.CreateFileFromUploadParams{
		{UploadFileID: []byte("upload-1")},
		{UploadFileID: []byte("upload-1")},
	})

	if len(results) != 2 {
		t.Fatalf("expected 2 results, got %d", len(results))
	}
	if results[0].Err != nil {
		t.Fatalf("expected first create success, got %v", results[0].Err)
	}
	if !errors.Is(results[1].Err, filemanager.ErrFileAlreadyFinalized) {
		t.Fatalf("expected second create ErrFileAlreadyFinalized, got %v", results[1].Err)
	}
}

func TestFileManagerServiceCreateFileNodeFromUpload_RequiresValidatedStatus(t *testing.T) {
	fileRepo := &fileManagerFileRepoMock{
		getByIDResult: &filemanager.File{
			ID:             []byte("upload-1"),
			FolderID:       []byte("root-folder"),
			Name:           "raw.pdf",
			NameNormalized: "raw.pdf",
			Status:         filemanager.FileStatusUploaded,
			NameSlot:       "tmp-slot",
		},
	}
	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})

	_, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
		UploadFileID: []byte("upload-1"),
	})
	if !errors.Is(err, filemanager.ErrFileNotEligibleForFinalize) {
		t.Fatalf("expected ErrFileNotEligibleForFinalize, got %v", err)
	}
	if fileRepo.finalizeCalled {
		t.Fatalf("expected finalize not called for non-eligible status")
	}
}

func TestFileManagerServiceSingleUploadAndCreateCompatibility(t *testing.T) {
	rootID := []byte("root-folder-id")
	files := map[string]*filemanager.File{}

	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{
		createFn: func(_ context.Context, row *filemanager.File) error {
			cp := *row
			files[string(row.ID)] = &cp
			return nil
		},
		getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
			row := files[string(id)]
			if row == nil {
				return nil, nil
			}
			cp := *row
			return &cp, nil
		},
		getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
			for _, row := range files {
				if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
					cp := *row
					return &cp, nil
				}
			}
			return nil, nil
		},
		finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
			row := files[string(params.ID)]
			if row == nil {
				return nil
			}
			row.FolderID = append([]byte(nil), params.FolderID...)
			row.Name = params.Name
			row.NameNormalized = params.NameNormalized
			row.Extension = params.Extension
			row.Status = filemanager.FileStatusReady
			row.NameSlot = "live"
			return nil
		},
	}
	storage := &fileManagerStorageMock{}
	fileQueue := &fileProcessingQueueMock{}
	actorID := uuidv7.MustBytes()

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(fileQueue)
	svc.SetFileStatusRealtimeEventWriter(fileRepo)

	uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "logo.png",
		ContentType: "image/png",
		SizeBytes:   8,
		Body:        strings.NewReader("png-data"),
		ActorID:     actorID,
	})
	if err != nil {
		t.Fatalf("upload single failed: %v", err)
	}
	if uploaded == nil || uploaded.Status != filemanager.FileStatusUploaded {
		t.Fatalf("expected uploaded status row")
	}

	// Simulate asynchronous file worker validation before finalize/create.
	stored := files[string(uploaded.ID)]
	if stored == nil {
		t.Fatalf("expected uploaded row persisted")
	}
	stored.Status = filemanager.FileStatusValidated

	finalName := "logo-final.png"
	created, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
		UploadFileID: uploaded.ID,
		Name:         &finalName,
		ActorID:      actorID,
	})
	if err != nil {
		t.Fatalf("create from upload failed: %v", err)
	}
	if created == nil || created.Status != filemanager.FileStatusReady {
		t.Fatalf("expected ready created row")
	}
	if created.Name != finalName {
		t.Fatalf("expected final name %q, got %q", finalName, created.Name)
	}
	if len(fileRepo.realtimeEvents) != 2 {
		t.Fatalf("expected 2 realtime events (uploaded + ready), got %d", len(fileRepo.realtimeEvents))
	}
	if got := filemanager.NormalizeFileStatus(fileRepo.realtimeEvents[0].Status); got != filemanager.FileStatusUploaded {
		t.Fatalf("expected first realtime status uploaded, got %s", got)
	}
	if got := filemanager.NormalizeFileStatus(fileRepo.realtimeEvents[1].Status); got != filemanager.FileStatusReady {
		t.Fatalf("expected second realtime status ready, got %s", got)
	}
}

func TestFileManagerServiceUploadFileNode_QueuePublishFailureMarksFailed(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{}
	storage := &fileManagerStorageMock{}
	fileQueue := &fileProcessingQueueMock{
		enqueueErr: errors.New("sqs unavailable"),
	}
	actorID := uuidv7.MustBytes()

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(fileQueue)
	svc.SetFileStatusRealtimeEventWriter(fileRepo)

	uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "draft.pdf",
		ContentType: "application/pdf",
		SizeBytes:   16,
		Body:        strings.NewReader("pdf"),
		ActorID:     actorID,
	})
	if err == nil {
		t.Fatalf("expected upload error when enqueue fails")
	}
	if uploaded != nil {
		t.Fatalf("expected nil uploaded row on enqueue failure")
	}
	if storage.putCalls != 1 {
		t.Fatalf("expected storage put called once")
	}
	if !fileQueue.enqueueCalled {
		t.Fatalf("expected queue enqueue called")
	}
	if !fileRepo.transitionStatusCalled {
		t.Fatalf("expected transition status called to mark failed")
	}
	if filemanager.NormalizeFileStatus(fileRepo.lastTransitionStatus.ExpectedFrom) != filemanager.FileStatusUploaded {
		t.Fatalf("expected transition from uploaded, got %q", fileRepo.lastTransitionStatus.ExpectedFrom)
	}
	if filemanager.NormalizeFileStatus(fileRepo.lastTransitionStatus.To) != filemanager.FileStatusFailed {
		t.Fatalf("expected transition to failed, got %q", fileRepo.lastTransitionStatus.To)
	}
	if len(fileRepo.realtimeEvents) < 2 {
		t.Fatalf("expected realtime events for uploaded + failed, got %d", len(fileRepo.realtimeEvents))
	}
	last := fileRepo.realtimeEvents[len(fileRepo.realtimeEvents)-1]
	if got := filemanager.NormalizeFileStatus(last.Status); got != filemanager.FileStatusFailed {
		t.Fatalf("expected last realtime status failed, got %s", got)
	}
}

func TestFileManagerServiceUploadFileNode_OutboxDurablePath(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{}
	storage := &fileManagerStorageMock{}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))

	uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "draft.pdf",
		ContentType: "application/pdf",
		SizeBytes:   16,
		Body:        strings.NewReader("pdf"),
	})
	if err != nil {
		t.Fatalf("expected upload success, got %v", err)
	}
	if uploaded == nil {
		t.Fatalf("expected uploaded row")
	}
	if uploaded.Status != filemanager.FileStatusUploaded {
		t.Fatalf("expected status uploaded, got %s", uploaded.Status)
	}
	if !fileRepo.withTransactionCalled {
		t.Fatalf("expected transaction path to be used")
	}
	if len(fileRepo.outboxMessages) != 1 {
		t.Fatalf("expected one outbox message, got %d", len(fileRepo.outboxMessages))
	}
	if fileRepo.transitionStatusCalled {
		t.Fatalf("expected no failed transition on successful outbox path")
	}
	if storage.deleteCalls != 0 {
		t.Fatalf("expected no storage cleanup on successful upload")
	}
}

func TestFileManagerServiceUploadFileNode_OutboxFailureRollsBackAndCleansStorage(t *testing.T) {
	rootID := []byte("root-folder-id")
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	fileRepo := &fileManagerFileRepoMock{
		outboxErr: errors.New("outbox write failed"),
	}
	storage := &fileManagerStorageMock{}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))

	uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "draft.pdf",
		ContentType: "application/pdf",
		SizeBytes:   16,
		Body:        strings.NewReader("pdf"),
	})
	if err == nil {
		t.Fatalf("expected upload error when outbox persist fails")
	}
	if uploaded != nil {
		t.Fatalf("expected nil uploaded row on outbox failure")
	}
	if !fileRepo.withTransactionCalled {
		t.Fatalf("expected transaction path to be used")
	}
	if storage.putCalls != 1 {
		t.Fatalf("expected storage put called once")
	}
	if storage.deleteCalls != 1 {
		t.Fatalf("expected storage cleanup after outbox failure, got %d", storage.deleteCalls)
	}
	if fileRepo.transitionStatusCalled {
		t.Fatalf("expected no transition-to-failed when transactional outbox persistence fails")
	}
}

func TestFileManagerServiceUploadFileNode_WithUploadPolicy(t *testing.T) {
	newUploadService := func(policy filemanager.FileUploadPolicy) (*FileManagerService, *fileManagerStorageMock, *fileManagerFileRepoMock) {
		rootID := []byte("root-folder-id")
		folderRepo := &fileManagerFolderRepoMock{
			getByParentNameResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
			getByIDAnyResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
		}
		fileRepo := &fileManagerFileRepoMock{}
		storage := &fileManagerStorageMock{}
		svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
		svc.SetObjectStorage(storage)
		svc.SetUploadPolicy(policy)
		svc.SetFileProcessingQueue(&fileProcessingQueueMock{})
		return svc, storage, fileRepo
	}

	t.Run("upload success when all policy checks pass", func(t *testing.T) {
		svc, storage, fileRepo := newUploadService(filemanager.FileUploadPolicy{
			MaxFileSizeBytes:  1024,
			AllowedMimeTypes:  []string{"image/png"},
			AllowedExtensions: []string{".png"},
			AllowEmptyFile:    false,
			MaxFilenameLength: 128,
		})

		row, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:        "Logo.PNG",
			ContentType: "image/png; charset=utf-8",
			SizeBytes:   8,
			Body:        strings.NewReader("png-data"),
		})
		if err != nil {
			t.Fatalf("upload failed: %v", err)
		}
		if row == nil {
			t.Fatalf("expected uploaded row")
		}
		if row.Status != filemanager.FileStatusUploaded {
			t.Fatalf("expected status uploaded, got %s", row.Status)
		}
		if storage.putCalls != 1 {
			t.Fatalf("expected 1 storage put call, got %d", storage.putCalls)
		}
		if !fileRepo.createCalled {
			t.Fatalf("expected db create called")
		}
	})

	t.Run("reject file too large", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			MaxFileSizeBytes: 128,
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:      "large.bin",
			SizeBytes: 129,
			Body:      strings.NewReader("data"),
		})
		if !errors.Is(err, filemanager.ErrFileTooLarge) {
			t.Fatalf("expected ErrFileTooLarge, got %v", err)
		}
		if storage.putCalls != 0 {
			t.Fatalf("expected no storage calls")
		}
	})

	t.Run("reject mime type not allowed", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			AllowedMimeTypes: []string{"image/png"},
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:        "doc.pdf",
			ContentType: "application/pdf",
			SizeBytes:   10,
			Body:        strings.NewReader("pdf"),
		})
		if !errors.Is(err, filemanager.ErrFileMimeTypeNotAllowed) {
			t.Fatalf("expected ErrFileMimeTypeNotAllowed, got %v", err)
		}
		if storage.putCalls != 0 {
			t.Fatalf("expected no storage calls")
		}
	})

	t.Run("reject extension not allowed", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			AllowedExtensions: []string{"png"},
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:      "doc.pdf",
			SizeBytes: 10,
			Body:      strings.NewReader("pdf"),
		})
		if !errors.Is(err, filemanager.ErrFileExtensionNotAllowed) {
			t.Fatalf("expected ErrFileExtensionNotAllowed, got %v", err)
		}
		if storage.putCalls != 0 {
			t.Fatalf("expected no storage calls")
		}
	})

	t.Run("reject empty file when not allowed", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			AllowEmptyFile: false,
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:      "empty.txt",
			SizeBytes: 0,
			Body:      strings.NewReader(""),
		})
		if !errors.Is(err, filemanager.ErrEmptyFileNotAllowed) {
			t.Fatalf("expected ErrEmptyFileNotAllowed, got %v", err)
		}
		if storage.putCalls != 0 {
			t.Fatalf("expected no storage calls")
		}
	})

	t.Run("reject file name too long", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			MaxFilenameLength: 8,
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:      "this-name-is-too-long.txt",
			SizeBytes: 12,
			Body:      strings.NewReader("payload"),
		})
		if !errors.Is(err, filemanager.ErrFileNameTooLong) {
			t.Fatalf("expected ErrFileNameTooLong, got %v", err)
		}
		if storage.putCalls != 0 {
			t.Fatalf("expected no storage calls")
		}
	})

	t.Run("empty allowed-list config allows mime and extension", func(t *testing.T) {
		svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
			MaxFileSizeBytes:  1024,
			MaxFilenameLength: 255,
		})
		_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
			Name:        "archive.unknownext",
			ContentType: "application/x-custom",
			SizeBytes:   11,
			Body:        strings.NewReader("custom-data"),
		})
		if err != nil {
			t.Fatalf("expected upload allowed for empty allow-list config, got %v", err)
		}
		if storage.putCalls != 1 {
			t.Fatalf("expected storage put called once")
		}
	})
}

func TestFileManagerServiceRequestFileUploadIntent(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		fileRepo := &fileManagerFileRepoMock{}
		intentRepo := &fileUploadIntentRepoMock{}
		storage := &fileManagerStorageMock{
			presignPut: filemanager.PresignedPutObject{
				Bucket:    "wucher-file-dev",
				ObjectKey: "files/2026/04/05/object.pdf",
				Method:    "PUT",
				URL:       "https://example.local/upload",
				Headers:   map[string]string{"content-type": "application/pdf"},
				ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
			},
		}

		svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
		svc.SetObjectStorage(storage)
		svc.SetFileUploadIntentRepository(intentRepo)
		svc.SetUploadPolicy(filemanager.FileUploadPolicy{
			MaxFileSizeBytes:  1024 * 1024,
			AllowedMimeTypes:  []string{"application/pdf"},
			AllowedExtensions: []string{"pdf"},
			AllowEmptyFile:    false,
		})

		grant, err := svc.RequestFileUploadIntent(context.Background(), filemanager.RequestFileUploadIntentParams{
			Name:        "policy.pdf",
			ContentType: "application/pdf; charset=utf-8",
			SizeBytes:   120,
			ActorID:     uuidv7.MustBytes(),
		})
		if err != nil {
			t.Fatalf("RequestFileUploadIntent() error = %v", err)
		}
		if grant == nil {
			t.Fatalf("expected upload intent grant")
		}
		if !intentRepo.createCalled {
			t.Fatalf("expected upload intent to be persisted")
		}
		if storage.presignPutCalls != 1 {
			t.Fatalf("expected presign put called once, got %d", storage.presignPutCalls)
		}
		if intentRepo.lastCreate == nil || intentRepo.lastCreate.Status != filemanager.FileUploadIntentStatusPending {
			t.Fatalf("expected pending upload intent status")
		}
		if grant.Method != "PUT" {
			t.Fatalf("expected PUT method, got %q", grant.Method)
		}
	})

	t.Run("reject invalid policy before presign", func(t *testing.T) {
		fileRepo := &fileManagerFileRepoMock{}
		intentRepo := &fileUploadIntentRepoMock{}
		storage := &fileManagerStorageMock{}

		svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
		svc.SetObjectStorage(storage)
		svc.SetFileUploadIntentRepository(intentRepo)
		svc.SetUploadPolicy(filemanager.FileUploadPolicy{
			MaxFileSizeBytes: 100,
		})

		grant, err := svc.RequestFileUploadIntent(context.Background(), filemanager.RequestFileUploadIntentParams{
			Name:        "large.bin",
			ContentType: "application/octet-stream",
			SizeBytes:   200,
			ActorID:     uuidv7.MustBytes(),
		})
		if !errors.Is(err, filemanager.ErrFileTooLarge) {
			t.Fatalf("expected ErrFileTooLarge, got %v", err)
		}
		if grant != nil {
			t.Fatalf("expected nil grant on policy failure")
		}
		if storage.presignPutCalls != 0 {
			t.Fatalf("expected no presign call when policy validation fails")
		}
		if intentRepo.createCalled {
			t.Fatalf("expected no upload intent persistence when policy validation fails")
		}
	})
}

func TestFileManagerServiceCompleteFileUploadIntent(t *testing.T) {
	newService := func(headMeta filemanager.ObjectMetadata, headErr error) (*FileManagerService, *fileManagerFileRepoMock, *fileManagerStorageMock, []byte) {
		rootID := uuidv7.MustBytes()
		intentID := uuidv7.MustBytes()
		actorID := uuidv7.MustBytes()
		intent := &filemanager.FileUploadIntent{
			ID:             intentID,
			Name:           "evidence.pdf",
			NameNormalized: "evidence.pdf",
			Extension:      "pdf",
			SizeBytes:      42,
			MimeType:       "application/pdf",
			Bucket:         "test-bucket",
			ObjectKey:      "files/2026/04/05/evidence.pdf",
			Status:         filemanager.FileUploadIntentStatusPending,
			ExpiresAt:      time.Now().UTC().Add(5 * time.Minute),
			CreatedBy:      actorID,
			UpdatedBy:      actorID,
		}

		folderRepo := &fileManagerFolderRepoMock{
			getByParentNameResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
			getByIDAnyResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
		}
		fileRepo := &fileManagerFileRepoMock{
			uploadIntentByID: map[string]*filemanager.FileUploadIntent{
				string(intentID): intent,
			},
		}
		storage := &fileManagerStorageMock{
			headMeta: headMeta,
			headErr:  headErr,
		}

		svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
		svc.SetObjectStorage(storage)
		svc.SetFileUploadIntentRepository(fileRepo)
		svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))

		return svc, fileRepo, storage, intentID
	}

	t.Run("success persists uploaded file and outbox", func(t *testing.T) {
		svc, fileRepo, _, intentID := newService(filemanager.ObjectMetadata{
			Bucket:      "test-bucket",
			ObjectKey:   "files/2026/04/05/evidence.pdf",
			SizeBytes:   42,
			ContentType: "application/pdf",
		}, nil)

		row, err := svc.CompleteFileUploadIntent(context.Background(), filemanager.CompleteFileUploadIntentParams{
			UploadIntentID: intentID,
			ActorID:        uuidv7.MustBytes(),
		})
		if err != nil {
			t.Fatalf("CompleteFileUploadIntent() error = %v", err)
		}
		if row == nil {
			t.Fatalf("expected completed file row")
		}
		if row.Status != filemanager.FileStatusUploaded {
			t.Fatalf("expected status uploaded, got %q", row.Status)
		}
		if !fileRepo.withTransactionCalled {
			t.Fatalf("expected transactional durable path")
		}
		if !fileRepo.createCalled {
			t.Fatalf("expected file metadata create call")
		}
		if len(fileRepo.outboxMessages) != 1 {
			t.Fatalf("expected one outbox message, got %d", len(fileRepo.outboxMessages))
		}
		intent := fileRepo.uploadIntentByID[string(intentID)]
		if intent == nil || intent.Status != filemanager.FileUploadIntentStatusCompleted {
			t.Fatalf("expected upload intent completed")
		}
	})

	t.Run("fails when uploaded object is missing", func(t *testing.T) {
		svc, fileRepo, _, intentID := newService(filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound)
		row, err := svc.CompleteFileUploadIntent(context.Background(), filemanager.CompleteFileUploadIntentParams{
			UploadIntentID: intentID,
			ActorID:        uuidv7.MustBytes(),
		})
		if !errors.Is(err, filemanager.ErrUploadObjectNotFound) {
			t.Fatalf("expected ErrUploadObjectNotFound, got %v", err)
		}
		if row != nil {
			t.Fatalf("expected nil row when object not found")
		}
		if fileRepo.createCalled {
			t.Fatalf("expected no metadata row create when object verification fails")
		}
		if fileRepo.withTransactionCalled {
			t.Fatalf("expected no transaction when object verification fails")
		}
	})
}

func TestFileManagerServiceCreateFileNodeFromUploadIntent(t *testing.T) {
	newService := func(headMeta filemanager.ObjectMetadata, headErr error) (*FileManagerService, *fileManagerFolderRepoMock, *fileManagerFileRepoMock, []byte, []byte) {
		rootID := uuidv7.MustBytes()
		intentID := uuidv7.MustBytes()
		actorID := uuidv7.MustBytes()
		intent := &filemanager.FileUploadIntent{
			ID:             intentID,
			Name:           "upload-source.pdf",
			NameNormalized: "upload-source.pdf",
			Extension:      "pdf",
			SizeBytes:      42,
			MimeType:       "application/pdf",
			Bucket:         "test-bucket",
			ObjectKey:      "files/2026/04/05/upload-source.pdf",
			Status:         filemanager.FileUploadIntentStatusPending,
			ExpiresAt:      time.Now().UTC().Add(5 * time.Minute),
			CreatedBy:      actorID,
			UpdatedBy:      actorID,
		}

		folderRepo := &fileManagerFolderRepoMock{
			getByParentNameResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
			getByIDAnyResult: &filemanager.Folder{
				ID:             rootID,
				Name:           filemanager.SystemRootFolderName,
				NameNormalized: filemanager.SystemRootFolderName,
			},
		}
		fileRepo := &fileManagerFileRepoMock{
			uploadIntentByID: map[string]*filemanager.FileUploadIntent{
				string(intentID): intent,
			},
		}
		stored := &filemanager.File{}
		fileRepo.getByFolderNameFn = func(_ context.Context, _ []byte, nameNormalized string) (*filemanager.File, error) {
			if len(stored.ID) == 0 {
				return nil, nil
			}
			if stored.NameNormalized == nameNormalized {
				cp := *stored
				return &cp, nil
			}
			return nil, nil
		}
		fileRepo.createFn = func(_ context.Context, row *filemanager.File) error {
			if len(row.ID) == 0 {
				row.ID = uuidv7.MustBytes()
			}
			cp := *row
			cp.ID = append([]byte(nil), row.ID...)
			cp.FolderID = append([]byte(nil), row.FolderID...)
			*stored = cp
			fileRepo.getByIDAnyResult = stored
			fileRepo.getByIDResult = stored
			return nil
		}
		fileRepo.updateFn = func(_ context.Context, row *filemanager.File) error {
			cp := *row
			cp.ID = append([]byte(nil), row.ID...)
			cp.FolderID = append([]byte(nil), row.FolderID...)
			*stored = cp
			fileRepo.getByIDAnyResult = stored
			fileRepo.getByIDResult = stored
			return nil
		}

		storage := &fileManagerStorageMock{
			headMeta: headMeta,
			headErr:  headErr,
		}

		svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
		svc.SetObjectStorage(storage)
		svc.SetFileUploadIntentRepository(fileRepo)
		svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))

		return svc, folderRepo, fileRepo, intentID, actorID
	}

	t.Run("success", func(t *testing.T) {
		svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{
			Bucket:      "test-bucket",
			ObjectKey:   "files/2026/04/05/upload-source.pdf",
			SizeBytes:   42,
			ContentType: "application/pdf",
		}, nil)

		row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
			UploadIntentID: intentID,
			Name:           "policy-final.pdf",
			ActorID:        actorID,
			IsTemplate:     true,
		})
		if err != nil {
			t.Fatalf("CreateFileNodeFromUploadIntent() error = %v", err)
		}
		if row == nil {
			t.Fatalf("expected created file row")
		}
		if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusUploaded {
			t.Fatalf("expected uploaded status, got %q", row.Status)
		}
		if row.Name != "policy-final.pdf" {
			t.Fatalf("expected renamed file, got %q", row.Name)
		}
		if !row.IsTemplate {
			t.Fatalf("expected template flag to be persisted")
		}
		if !strings.EqualFold(strings.TrimSpace(row.NameSlot), "live") {
			t.Fatalf("expected live name slot, got %q", row.NameSlot)
		}
		if !fileRepo.withTransactionCalled {
			t.Fatalf("expected durable transaction path")
		}
		if len(fileRepo.outboxMessages) != 1 {
			t.Fatalf("expected outbox message created, got %d", len(fileRepo.outboxMessages))
		}
		if !fileRepo.updateCalled {
			t.Fatalf("expected file metadata move/update call")
		}
		intent := fileRepo.uploadIntentByID[string(intentID)]
		if intent == nil || intent.Status != filemanager.FileUploadIntentStatusCompleted {
			t.Fatalf("expected upload intent completed")
		}
	})

	t.Run("auto rename when target name already exists", func(t *testing.T) {
		svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{
			Bucket:      "test-bucket",
			ObjectKey:   "files/2026/04/05/upload-source.pdf",
			SizeBytes:   42,
			ContentType: "application/pdf",
		}, nil)

		existingID := uuidv7.MustBytes()
		fileRepo.getByFolderNameFn = func(_ context.Context, _ []byte, nameNormalized string) (*filemanager.File, error) {
			if nameNormalized == "policy-final.pdf" {
				return &filemanager.File{
					ID:             existingID,
					Name:           "policy-final.pdf",
					NameNormalized: "policy-final.pdf",
					NameSlot:       "live",
				}, nil
			}
			return nil, nil
		}

		row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
			UploadIntentID: intentID,
			Name:           "policy-final.pdf",
			ActorID:        actorID,
			IsTemplate:     false,
		})
		if err != nil {
			t.Fatalf("CreateFileNodeFromUploadIntent() error = %v", err)
		}
		if row == nil {
			t.Fatalf("expected created file row")
		}
		if row.Name != "policy-final (1).pdf" {
			t.Fatalf("expected auto renamed file, got %q", row.Name)
		}
		if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusUploaded {
			t.Fatalf("expected uploaded status, got %q", row.Status)
		}
		if !strings.EqualFold(strings.TrimSpace(row.NameSlot), "live") {
			t.Fatalf("expected live name slot, got %q", row.NameSlot)
		}
	})

	t.Run("fails when uploaded object is missing", func(t *testing.T) {
		svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound)

		row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
			UploadIntentID: intentID,
			Name:           "policy-final.pdf",
			ActorID:        actorID,
		})
		if !errors.Is(err, filemanager.ErrUploadObjectNotFound) {
			t.Fatalf("expected ErrUploadObjectNotFound, got %v", err)
		}
		if row != nil {
			t.Fatalf("expected nil row when object not found")
		}
		if fileRepo.createCalled {
			t.Fatalf("expected no file metadata create when object verification fails")
		}
		if fileRepo.withTransactionCalled {
			t.Fatalf("expected no durable transaction when object verification fails")
		}
	})
}

func TestFileManagerServiceCreateFileNodeFromUpload_AutoRenameDuplicate(t *testing.T) {
	uploadID := []byte("upload-1")
	targetFolderID := []byte("folder-1")
	existingID := []byte("existing-1")

	files := map[string]*filemanager.File{
		string(uploadID): {
			ID:             append([]byte(nil), uploadID...),
			FolderID:       append([]byte(nil), targetFolderID...),
			Name:           "report.pdf",
			NameNormalized: "report.pdf",
			Status:         filemanager.FileStatusValidated,
			NameSlot:       "tmp-slot",
		},
		string(existingID): {
			ID:             append([]byte(nil), existingID...),
			FolderID:       append([]byte(nil), targetFolderID...),
			Name:           "report.pdf",
			NameNormalized: "report.pdf",
			Status:         filemanager.FileStatusReady,
			NameSlot:       "live",
		},
	}

	fileRepo := &fileManagerFileRepoMock{
		getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
			row := files[string(id)]
			if row == nil {
				return nil, nil
			}
			cp := *row
			return &cp, nil
		},
		getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
			for _, row := range files {
				if row.DeletedAt != nil || row.NameSlot != "live" {
					continue
				}
				if string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
					cp := *row
					return &cp, nil
				}
			}
			return nil, nil
		},
		finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
			row := files[string(params.ID)]
			if row == nil {
				return nil
			}
			row.FolderID = append([]byte(nil), params.FolderID...)
			row.Name = params.Name
			row.NameNormalized = params.NameNormalized
			row.Extension = params.Extension
			row.Status = filemanager.FileStatusReady
			row.NameSlot = "live"
			return nil
		},
	}

	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
	targetName := "report.pdf"
	created, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
		UploadFileID: uploadID,
		Name:         &targetName,
		ActorID:      uuidv7.MustBytes(),
	})
	if err != nil {
		t.Fatalf("CreateFileNodeFromUpload() error = %v", err)
	}
	if created == nil {
		t.Fatalf("expected created file")
	}
	if created.Name != "report (1).pdf" {
		t.Fatalf("expected auto renamed file, got %q", created.Name)
	}
}

func TestNormalizeFolderSort(t *testing.T) {
	cases := map[string]string{
		"name":        "name ASC",
		"-name":       "name DESC",
		"depth":       "depth ASC",
		"-depth":      "depth DESC",
		"created_at":  "created_at ASC",
		"-created_at": "created_at DESC",
		"updated_at":  "updated_at ASC",
		"-updated_at": "updated_at DESC",
		"updated_by":  "updated_at ASC",
		"-updated_by": "updated_at DESC",
		"unknown":     "name ASC",
		"":            "name ASC",
	}
	for in, want := range cases {
		if got := normalizeFolderSort(in); got != want {
			t.Fatalf("normalizeFolderSort(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestNormalizeFileSort(t *testing.T) {
	cases := map[string]string{
		"name":        "name ASC",
		"-name":       "name DESC",
		"size_bytes":  "size_bytes ASC",
		"-size_bytes": "size_bytes DESC",
		"mime_type":   "mime_type ASC",
		"-mime_type":  "mime_type DESC",
		"created_at":  "created_at ASC",
		"-created_at": "created_at DESC",
		"updated_at":  "updated_at ASC",
		"-updated_at": "updated_at DESC",
		"updated_by":  "updated_at ASC",
		"-updated_by": "updated_at DESC",
		"unknown":     "name ASC",
		"":            "name ASC",
	}
	for in, want := range cases {
		if got := normalizeFileSort(in); got != want {
			t.Fatalf("normalizeFileSort(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestNormalizeAttachmentSort(t *testing.T) {
	cases := map[string]string{
		"sort_order":  "sort_order ASC, created_at DESC",
		"-sort_order": "sort_order DESC, created_at DESC",
		"created_at":  "created_at ASC",
		"-created_at": "created_at DESC",
		"updated_at":  "updated_at ASC",
		"-updated_at": "updated_at DESC",
		"updated_by":  "updated_at ASC",
		"-updated_by": "updated_at DESC",
		"unknown":     "sort_order ASC, created_at DESC",
		"":            "sort_order ASC, created_at DESC",
	}
	for in, want := range cases {
		if got := normalizeAttachmentSort(in); got != want {
			t.Fatalf("normalizeAttachmentSort(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestFileManagerServiceDeleteFile_EmitsTrashedRealtimeEvent(t *testing.T) {
	fileID := uuidv7.MustBytes()
	actorID := uuidv7.MustBytes()
	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:        append([]byte(nil), fileID...),
			Name:      "logo.jpg",
			FolderID:  uuidv7.MustBytes(),
			Status:    filemanager.FileStatusUploaded,
			CreatedBy: append([]byte(nil), actorID...),
		},
	}

	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetFileStatusRealtimeEventWriter(fileRepo)

	if err := svc.DeleteFile(context.Background(), fileID, actorID, nil); err != nil {
		t.Fatalf("delete file: %v", err)
	}
	if len(fileRepo.realtimeEvents) != 1 {
		t.Fatalf("expected 1 realtime event, got %d", len(fileRepo.realtimeEvents))
	}
	event := fileRepo.realtimeEvents[0]
	if got := filemanager.NormalizeFileStatus(event.Status); got != filemanager.FileStatusTrashed {
		t.Fatalf("expected trashed realtime status, got %s", got)
	}
	if string(event.FileID) != string(fileID) {
		t.Fatalf("expected realtime event file id to match deleted file")
	}
	if string(event.UserID) != string(actorID) {
		t.Fatalf("expected realtime event user id to match actor")
	}
}

func TestFileManagerServiceRestoreFile_EmitsReadyRealtimeEvent(t *testing.T) {
	fileID := uuidv7.MustBytes()
	folderID := uuidv7.MustBytes()
	actorID := uuidv7.MustBytes()
	deletedAt := time.Now().UTC().Add(-time.Minute)
	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:             append([]byte(nil), fileID...),
			Name:           "logo.jpg",
			NameNormalized: "logo.jpg",
			FolderID:       append([]byte(nil), folderID...),
			Status:         filemanager.FileStatusTrashed,
			DeletedAt:      &deletedAt,
			CreatedBy:      append([]byte(nil), actorID...),
		},
	}
	folderRepo := &fileManagerFolderRepoMock{
		getByIDResult: &filemanager.Folder{ID: append([]byte(nil), folderID...), Name: "Assets"},
	}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetFileStatusRealtimeEventWriter(fileRepo)

	if err := svc.RestoreFile(context.Background(), fileID, nil, false, actorID); err != nil {
		t.Fatalf("restore file: %v", err)
	}
	if len(fileRepo.realtimeEvents) != 1 {
		t.Fatalf("expected 1 realtime event, got %d", len(fileRepo.realtimeEvents))
	}
	event := fileRepo.realtimeEvents[0]
	if got := filemanager.NormalizeFileStatus(event.Status); got != filemanager.FileStatusReady {
		t.Fatalf("expected ready realtime status, got %s", got)
	}
	if string(event.FileID) != string(fileID) {
		t.Fatalf("expected realtime event file id to match restored file")
	}
	if string(event.UserID) != string(actorID) {
		t.Fatalf("expected realtime event user id to match actor")
	}
}

func TestFileManagerServiceDeleteFile_TouchesParentFolderUpdatedAt(t *testing.T) {
	fileID := uuidv7.MustBytes()
	folderID := uuidv7.MustBytes()
	actorID := uuidv7.MustBytes()
	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:       append([]byte(nil), fileID...),
			FolderID: append([]byte(nil), folderID...),
			Status:   filemanager.FileStatusReady,
		},
	}
	folderRepo := &fileManagerFolderRepoMock{
		getByIDAnyResult: &filemanager.Folder{
			ID: append([]byte(nil), folderID...),
		},
	}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	if err := svc.DeleteFile(context.Background(), fileID, actorID, nil); err != nil {
		t.Fatalf("delete file: %v", err)
	}
	if !folderRepo.updateCalled {
		t.Fatalf("expected parent folder to be touched")
	}
	if folderRepo.lastUpdate == nil || folderRepo.lastUpdate.UpdatedAt.IsZero() {
		t.Fatalf("expected folder updated_at to be set")
	}
	if string(folderRepo.lastUpdate.UpdatedBy) != string(actorID) {
		t.Fatalf("expected folder updated_by to match actor")
	}
}

func TestFileManagerServiceMoveFileNode_TouchesSourceAndTargetFolders(t *testing.T) {
	fileID := uuidv7.MustBytes()
	sourceFolderID := uuidv7.MustBytes()
	targetFolderID := uuidv7.MustBytes()
	actorID := uuidv7.MustBytes()

	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:             append([]byte(nil), fileID...),
			FolderID:       append([]byte(nil), sourceFolderID...),
			Name:           "doc.pdf",
			NameNormalized: "doc.pdf",
		},
		getByIDResult: &filemanager.File{
			ID:             append([]byte(nil), fileID...),
			FolderID:       append([]byte(nil), targetFolderID...),
			Name:           "doc.pdf",
			NameNormalized: "doc.pdf",
		},
	}
	folderRepo := &fileManagerFolderRepoMock{
		getByIDAnyResult: &filemanager.Folder{
			ID: uuidv7.MustBytes(),
		},
	}

	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
	_, err := svc.MoveFileNode(context.Background(), fileID, filemanager.MoveFileParams{
		FolderID:       targetFolderID,
		FolderProvided: true,
		ActorID:        actorID,
	})
	if err != nil {
		t.Fatalf("move file: %v", err)
	}
	if folderRepo.updateCalls != 2 {
		t.Fatalf("expected both source and target folders touched, got %d updates", folderRepo.updateCalls)
	}
}

func TestFileManagerServiceUploadFileNode_SetsTempLifecycleAndTag(t *testing.T) {
	rootID := uuidv7.MustBytes()
	fileRepo := &fileManagerFileRepoMock{
		getByIDResult: &filemanager.File{
			ID:             uuidv7.MustBytes(),
			FolderID:       rootID,
			Name:           "proof.pdf",
			NameNormalized: "proof.pdf",
			ObjectKey:      "files/2026/04/27/proof.pdf",
			Status:         filemanager.FileStatusUploaded,
		},
	}
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	storage := &fileManagerStorageMock{}
	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{
		EnableS3Tagging: true,
	})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(&fileProcessingQueueMock{})

	_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "proof.pdf",
		ContentType: "application/pdf",
		SizeBytes:   10,
		Body:        strings.NewReader("0123456789"),
	})
	if err != nil {
		t.Fatalf("UploadFileNode() error = %v", err)
	}
	if !fileRepo.updateLifecycleCalled {
		t.Fatalf("expected lifecycle update to be called")
	}
	if storage.taggingCalls == 0 {
		t.Fatalf("expected object tagging to be called")
	}
	if storage.lastTags["gc"] != "temp" {
		t.Fatalf("expected gc=temp tag, got %v", storage.lastTags)
	}
}

func TestFileManagerServiceReconcileFileLifecycle_MarksOrphanPendingWhenUnreferenced(t *testing.T) {
	fileID := uuidv7.MustBytes()
	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:              append([]byte(nil), fileID...),
			ObjectKey:       "files/2026/04/27/a.pdf",
			LifecycleStatus: filemanager.FileLifecycleStatusActive,
		},
		isFileReferencedResult: false,
	}
	storage := &fileManagerStorageMock{}
	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{
		EnableS3Tagging: true,
	})
	svc.SetObjectStorage(storage)

	if err := svc.ReconcileFileLifecycle(context.Background(), fileID, uuidv7.MustBytes()); err != nil {
		t.Fatalf("ReconcileFileLifecycle() error = %v", err)
	}
	if !fileRepo.updateLifecycleCalled {
		t.Fatalf("expected lifecycle update called")
	}
	if storage.lastTags["gc"] != "keep" {
		t.Fatalf("expected gc=keep tag, got %v", storage.lastTags)
	}
}

func TestFileManagerServiceCleanupOrphanPendingFiles_DryRunAndDelete(t *testing.T) {
	fileID := uuidv7.MustBytes()
	orphanedAt := time.Now().UTC().Add(-48 * time.Hour)
	fileRepo := &fileManagerFileRepoMock{
		listByLifecycleResult: []filemanager.File{{
			ID:              append([]byte(nil), fileID...),
			ObjectKey:       "files/2026/04/25/a.pdf",
			LifecycleStatus: filemanager.FileLifecycleStatusOrphanPending,
			OrphanedAt:      &orphanedAt,
		}},
		isFileReferencedResult: false,
	}
	storage := &fileManagerStorageMock{}
	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{
		EnableS3Tagging: true,
	})
	svc.SetObjectStorage(storage)

	stats, err := svc.CleanupOrphanPendingFiles(context.Background(), time.Now().UTC(), 24*time.Hour, 10, true)
	if err != nil {
		t.Fatalf("dry-run cleanup error = %v", err)
	}
	if stats.DryRun != true || stats.Scanned != 1 || stats.Deleted != 0 {
		t.Fatalf("unexpected dry-run stats: %+v", stats)
	}

	stats, err = svc.CleanupOrphanPendingFiles(context.Background(), time.Now().UTC(), 24*time.Hour, 10, false)
	if err != nil {
		t.Fatalf("cleanup error = %v", err)
	}
	if stats.Deleted != 1 {
		t.Fatalf("expected 1 deleted file, got %+v", stats)
	}
	if storage.deleteCalls == 0 {
		t.Fatalf("expected s3 delete called")
	}
}

func TestFileManagerServicePurgeFileNode_DeletesThumbnailObject(t *testing.T) {
	fileID := uuidv7.MustBytes()
	deletedAt := time.Now().UTC().Add(-time.Minute)
	thumbKey := "files/2026/04/27/thumb-photo.jpg"
	objectKey := "files/2026/04/27/photo.jpg"

	fileRepo := &fileManagerFileRepoMock{
		getByIDAnyResult: &filemanager.File{
			ID:                 append([]byte(nil), fileID...),
			ObjectKey:          objectKey,
			ThumbnailObjectKey: &thumbKey,
			DeletedAt:          &deletedAt,
		},
	}
	storage := &fileManagerStorageMock{}
	svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
	svc.SetObjectStorage(storage)

	if err := svc.PurgeFileNode(context.Background(), fileID); err != nil {
		t.Fatalf("purge file node: %v", err)
	}
	if !fileRepo.purgeCalled {
		t.Fatalf("expected purge repository called")
	}

	deadline := time.Now().Add(300 * time.Millisecond)
	for time.Now().Before(deadline) {
		if storage.deleteCalls >= 2 {
			break
		}
		time.Sleep(10 * time.Millisecond)
	}

	if storage.deleteCalls != 2 {
		t.Fatalf("expected 2 deleted objects (main+thumb), got %d", storage.deleteCalls)
	}
	hasObject := false
	hasThumb := false
	for i := range storage.deletedKeys {
		if storage.deletedKeys[i] == objectKey {
			hasObject = true
		}
		if storage.deletedKeys[i] == thumbKey {
			hasThumb = true
		}
	}
	if !hasObject || !hasThumb {
		t.Fatalf("expected deleted keys to include main and thumb, got %#v", storage.deletedKeys)
	}
}

func TestFileManagerServiceUploadFileNode_TaggingFailureDoesNotFailUpload(t *testing.T) {
	rootID := uuidv7.MustBytes()
	fileRepo := &fileManagerFileRepoMock{
		getByIDResult: &filemanager.File{
			ID:             uuidv7.MustBytes(),
			FolderID:       rootID,
			Name:           "proof.pdf",
			NameNormalized: "proof.pdf",
			ObjectKey:      "files/2026/04/27/proof.pdf",
			Status:         filemanager.FileStatusUploaded,
		},
	}
	folderRepo := &fileManagerFolderRepoMock{
		getByParentNameResult: &filemanager.Folder{
			ID:             rootID,
			Name:           filemanager.SystemRootFolderName,
			NameNormalized: filemanager.SystemRootFolderName,
		},
	}
	storage := &fileManagerStorageMock{taggingErr: errors.New("tagging denied")}
	svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{
		EnableS3Tagging: true,
	})
	svc.SetObjectStorage(storage)
	svc.SetFileProcessingQueue(&fileProcessingQueueMock{})

	if _, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
		Name:        "proof.pdf",
		ContentType: "application/pdf",
		SizeBytes:   10,
		Body:        strings.NewReader("0123456789"),
	}); err != nil {
		t.Fatalf("upload should still succeed when tagging fails, err=%v", err)
	}
}
