package service

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"gitlab.com/mbugroup/lti-api.git/internal/common/exportprogress"
	commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
	commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
	entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
	middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
	expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto"
	repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
	validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations"
	nonstockRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories"
	supplierRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
	projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
	"gitlab.com/mbugroup/lti-api.git/internal/utils"
	approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"

	"github.com/go-playground/validator/v10"
	"github.com/gofiber/fiber/v2"
	"github.com/sirupsen/logrus"
	"gorm.io/gorm"
)

type ExpenseService interface {
	GetAll(ctx *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error)
	GetOne(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error)
	CreateOne(ctx *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error)
	UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error)
	DeleteOne(ctx *fiber.Ctx, id uint64) error
	CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error)
	CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error)
	UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error)
	DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error
	Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error)
	BulkApproveToStatus(ctx *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error)
	GetProgressRows(ctx *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error)
}

type expenseService struct {
	Log                     *logrus.Logger
	Validate                *validator.Validate
	Repository              repository.ExpenseRepository
	SupplierRepo            supplierRepo.SupplierRepository
	NonstockRepo            nonstockRepo.NonstockRepository
	ApprovalSvc             commonSvc.ApprovalService
	RealizationRepository   repository.ExpenseRealizationRepository
	ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository
	DocumentSvc             commonSvc.DocumentService
}

func NewExpenseService(repo repository.ExpenseRepository, supplierRepo supplierRepo.SupplierRepository, nonstockRepo nonstockRepo.NonstockRepository, approvalSvc commonSvc.ApprovalService, realizationRepo repository.ExpenseRealizationRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, documentSvc commonSvc.DocumentService, validate *validator.Validate) ExpenseService {
	return &expenseService{
		Log:                     utils.Log,
		Validate:                validate,
		Repository:              repo,
		SupplierRepo:            supplierRepo,
		NonstockRepo:            nonstockRepo,
		ApprovalSvc:             approvalSvc,
		RealizationRepository:   realizationRepo,
		ProjectFlockKandangRepo: projectFlockKandangRepo,
		DocumentSvc:             documentSvc,
	}
}

func (s expenseService) withRelations(db *gorm.DB) *gorm.DB {
	return db.
		Preload("CreatedUser").
		Preload("Supplier").
		Preload("Location").
		Preload("Nonstocks.Nonstock").
		Preload("Nonstocks.Realization").
		Preload("Nonstocks.ProjectFlockKandang.Kandang.Location").
		Preload("Nonstocks.Kandang").
		Preload("Nonstocks.Kandang.Location").
		Preload("Documents", func(db *gorm.DB) *gorm.DB {
			return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpense))
		}).
		Preload("RealizationDocuments", func(db *gorm.DB) *gorm.DB {
			return db.Where("documentable_type = ?", string(utils.DocumentableTypeExpenseRealization))
		})
}

func normalizeExpenseApprovalStatusFilter(raw string) string {
	switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(raw), " ", "_")) {
	case "HEAD_AREA", "APPROVAL_HEAD_AREA":
		return "Approval Head Area"
	case "UNIT_VICE_PRESIDENT", "APPROVAL_UNIT_VICE_PRESIDENT", "BUSINESS_UNIT_VICE_PRESIDENT", "APPROVAL_BUSINESS_UNIT_VICE_PRESIDENT":
		return "Approval Unit Vice President"
	case "FINANCE", "APPROVAL_FINANCE":
		return "Approval Finance"
	case "REALISASI":
		return "Realisasi"
	case "SELESAI":
		return "Selesai"
	case "DITOLAK", "REJECTED":
		return "REJECTED"
	default:
		return ""
	}
}

func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expenseDto.ExpenseListDTO, int64, error) {
	if err := s.Validate.Struct(params); err != nil {
		return nil, 0, err
	}

	var scopeErr error

	offset := (params.Page - 1) * params.Limit

	expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
		db = s.withRelations(db)
		db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
		db = db.Where("expenses.deleted_at IS NULL")

		if params.TransactionDate != "" {
			db = db.Where("DATE(expenses.transaction_date) = DATE(?)", params.TransactionDate)
		}
		if params.RealizationDate != "" {
			db = db.Where("DATE(expenses.realization_date) = DATE(?)", params.RealizationDate)
		}
		if params.LocationID > 0 {
			db = db.Where("expenses.location_id = ?", params.LocationID)
		}
		if params.VendorID > 0 {
			db = db.Where("expenses.supplier_id = ?", params.VendorID)
		}
		if params.Category != "" {
			db = db.Where("expenses.category = ?", params.Category)
		}
		if params.ProjectFlockID > 0 {
			projectFlockJSON := fmt.Sprintf("[%d]", params.ProjectFlockID)
			db = db.Where(`(
				EXISTS (
					SELECT 1
					FROM expense_nonstocks en
					LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
					WHERE en.expense_id = expenses.id
						AND (
							pfk.project_flock_id = ? OR
							en.kandang_id IN (
								SELECT kandang_id
								FROM project_flock_kandangs
								WHERE project_flock_id = ?
							)
						)
				) OR
				(expenses.project_flock_id IS NOT NULL AND expenses.project_flock_id::jsonb @> ?::jsonb)
			)`, params.ProjectFlockID, params.ProjectFlockID, projectFlockJSON)
		}
		if params.ProjectFlockKandangID > 0 {
			db = db.Where(`EXISTS (
				SELECT 1
				FROM expense_nonstocks en
				LEFT JOIN project_flock_kandangs selected_pfk ON selected_pfk.id = ?
				WHERE en.expense_id = expenses.id
					AND (
						en.project_flock_kandang_id = ? OR
						(selected_pfk.kandang_id IS NOT NULL AND en.kandang_id = selected_pfk.kandang_id)
					)
			)`, params.ProjectFlockKandangID, params.ProjectFlockKandangID)
		}

		latestApprovalSubQuery := s.Repository.DB().
			WithContext(c.Context()).
			Table("approvals").
			Select("DISTINCT ON (approvable_id) approvable_id, step_name, action, step_number").
			Where("approvable_type = ?", utils.ApprovalWorkflowExpense.String()).
			Order("approvable_id, action_at DESC, id DESC")

		if approvalStatus := normalizeExpenseApprovalStatusFilter(params.ApprovalStatus); approvalStatus != "" {
			if approvalStatus == "REJECTED" {
				db = db.Where(`EXISTS (
					SELECT 1
					FROM (?) AS latest_approval
					WHERE latest_approval.approvable_id = expenses.id
						AND latest_approval.action = ?
				)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
			} else {
				db = db.Where(`EXISTS (
					SELECT 1
					FROM (?) AS latest_approval
					WHERE latest_approval.approvable_id = expenses.id
						AND LOWER(latest_approval.step_name) = LOWER(?)
						AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
				)`, latestApprovalSubQuery, approvalStatus, string(entity.ApprovalActionRejected))
			}
		}

		switch strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(params.RealizationStatus), " ", "_")) {
		case "REALIZED", "SUDAH_REALISASI":
			db = db.Where(`EXISTS (
				SELECT 1
				FROM (?) AS latest_approval
				WHERE latest_approval.approvable_id = expenses.id
					AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
					AND latest_approval.step_number >= 5
			)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
		case "NOT_REALIZED", "BELUM_REALISASI":
			db = db.Where(`NOT EXISTS (
				SELECT 1
				FROM (?) AS latest_approval
				WHERE latest_approval.approvable_id = expenses.id
					AND (latest_approval.action IS NULL OR latest_approval.action <> ?)
					AND latest_approval.step_number >= 5
			)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
			db = db.Where(`NOT EXISTS (
				SELECT 1
				FROM (?) AS latest_approval
				WHERE latest_approval.approvable_id = expenses.id
					AND latest_approval.action = ?
			)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
		case "REJECTED", "DITOLAK":
			db = db.Where(`EXISTS (
				SELECT 1
				FROM (?) AS latest_approval
				WHERE latest_approval.approvable_id = expenses.id
					AND latest_approval.action = ?
			)`, latestApprovalSubQuery, string(entity.ApprovalActionRejected))
		}

		if search := strings.ToLower(strings.TrimSpace(params.Search)); search != "" {
			like := "%" + search + "%"
			db = db.Where(`(
				LOWER(COALESCE(expenses.reference_number, '')) LIKE ?
				OR LOWER(COALESCE(expenses.po_number, '')) LIKE ?
				OR LOWER(COALESCE(expenses.category, '')) LIKE ?
				OR EXISTS (
					SELECT 1
					FROM suppliers s
					WHERE s.id = expenses.supplier_id
						AND LOWER(COALESCE(s.name, '')) LIKE ?
				)
				OR EXISTS (
					SELECT 1
					FROM locations l
					WHERE l.id = expenses.location_id
						AND LOWER(COALESCE(l.name, '')) LIKE ?
				)
				OR EXISTS (
					SELECT 1
					FROM users u
					WHERE u.id = expenses.created_by
						AND LOWER(COALESCE(u.name, '')) LIKE ?
				)
				OR EXISTS (
					SELECT 1
					FROM expense_nonstocks en
					LEFT JOIN project_flock_kandangs pfk ON pfk.id = en.project_flock_kandang_id
					LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id
					LEFT JOIN kandangs k ON k.id = COALESCE(en.kandang_id, pfk.kandang_id)
					WHERE en.expense_id = expenses.id
						AND (
							LOWER(COALESCE(pf.flock_name, '')) LIKE ? OR
							LOWER(COALESCE(k.name, '')) LIKE ?
						)
				)
				OR EXISTS (
					SELECT 1
					FROM approvals a
					WHERE a.approvable_type = ?
						AND a.approvable_id = expenses.id
						AND (
							LOWER(COALESCE(a.step_name, '')) LIKE ? OR
							LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR
							LOWER(COALESCE(a.notes, '')) LIKE ?
						)
				)
			)`,
				like,
				like,
				like,
				like,
				like,
				like,
				like,
				like,
				utils.ApprovalWorkflowExpense.String(),
				like,
				like,
				like,
			)
		}
		return db.Order("expenses.created_at DESC").Order("expenses.updated_at DESC")
	})

	if scopeErr != nil {
		return nil, 0, scopeErr
	}
	if err != nil {

		return nil, 0, err
	}

	for i := range expenses {
		latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, uint(expenses[i].Id), func(db *gorm.DB) *gorm.DB {
			return db.Preload("ActionUser")
		})
		if err != nil {
			return nil, 0, err
		}
		expenses[i].LatestApproval = latestApproval
	}

	result := expenseDto.ToExpenseListDTOs(expenses)
	return result, total, nil
}

func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
	var scopeErr error

	expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
		db = s.withRelations(db)
		db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
		return db
	})
	if scopeErr != nil {
		return nil, scopeErr
	}
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {

			return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
		}
		return nil, err
	}

	approval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, func(db *gorm.DB) *gorm.DB {
		return db.Preload("ActionUser")
	})
	if err != nil {
		return nil, err
	}

	expense.LatestApproval = approval

	responseDTO := expenseDto.ToExpenseDetailDTO(expense)
	return &responseDTO, nil
}

func (s expenseService) GetProgressRows(c *fiber.Ctx, query *exportprogress.Query) ([]exportprogress.Row, error) {
	locationScope, err := middleware.ResolveLocationScope(c, s.Repository.DB())
	if err != nil {
		return nil, err
	}
	return s.Repository.GetProgressRows(c.Context(), query.StartDate, query.EndDate, locationScope.IDs, locationScope.Restrict)
}

func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expenseDto.ExpenseDetailDTO, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	supplierID := uint(req.SupplierID)

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: s.SupplierRepo.IdExists},
	); err != nil {
		return nil, err
	}

	for _, expenseNonstock := range req.ExpenseNonstocks {
		for _, costItem := range expenseNonstock.CostItems {
			nonstockId := uint(costItem.NonstockID)

			if err := commonSvc.EnsureRelations(c.Context(),
				commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists},
			); err != nil {
				return nil, err
			}

			supplierFound, err := s.NonstockRepo.IsNonstockAssociatedWithSupplier(c.Context(), nonstockId, req.SupplierID)
			if err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found")
				}
				return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check nonstock-supplier relation")
			}
			if !supplierFound {
				return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Nonstock ID %d does not belong to supplier ID %d", nonstockId, req.SupplierID))
			}
		}
	}

	expenseDate, err := utils.ParseDateString(req.TransactionDate)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
	}

	var expense *entity.Expense
	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {

		expenseRepoTx := repository.NewExpenseRepository(dbTransaction)
		expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(dbTransaction)
		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction))
		projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(dbTransaction)

		referenceNumber, err := GenerateExpenseReferenceNumber(c.Context(), expenseRepoTx)
		if err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number")
		}

		actorID, err := middleware.ActorIDFromContext(c)
		if err != nil {
			return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
		}
		createdBy := uint64(actorID)

		hasKandang := false
		for _, ens := range req.ExpenseNonstocks {
			if ens.KandangID != nil {
				hasKandang = true
				break
			}
		}

		var projectFlockIdJSON *string
		if !hasKandang && req.Category == string(utils.ExpenseCategoryBOP) {
			projectFlockRepoTx := projectFlockKandangRepo.NewProjectflockRepository(dbTransaction)
			activeProjectFlocks, err := projectFlockRepoTx.GetActiveByLocationID(c.Context(), req.LocationID)

			if err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location")
			}

			if len(activeProjectFlocks) > 0 {
				projectFlockIDs := make([]uint64, len(activeProjectFlocks))
				for i, pf := range activeProjectFlocks {
					projectFlockIDs[i] = uint64(pf.Id)
				}

				projectFlockIdsJSON, err := json.Marshal(projectFlockIDs)
				if err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids")
				}
				jsonStr := string(projectFlockIdsJSON)
				projectFlockIdJSON = &jsonStr
			}
		}

		expense = &entity.Expense{
			ReferenceNumber: referenceNumber,
			PoNumber:        req.PoNumber,
			Category:        req.Category,
			SupplierId:      req.SupplierID,
			LocationId:      req.LocationID,
			ProjectFlockId:  projectFlockIdJSON,
			TransactionDate: expenseDate,
			CreatedBy:       createdBy,
		}

		if err := expenseRepoTx.CreateOne(c.Context(), expense, nil); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense")
		}

		if len(req.ExpenseNonstocks) > 0 {

			for _, expenseNonstock := range req.ExpenseNonstocks {

				isAttachingToKandang := (expenseNonstock.KandangID != nil)

				var projectFlockKandangId *uint64
				var kandangId *uint64

				if isAttachingToKandang {
					kandangId = expenseNonstock.KandangID

					if req.Category == string(utils.ExpenseCategoryBOP) {

						projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
						if err != nil {
							if errors.Is(err, gorm.ErrRecordNotFound) {
								return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
							}
							return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
						}
						id := uint64(projectFlockKandang.Id)
						projectFlockKandangId = &id
					}

				} else {
					kandangId = nil
					projectFlockKandangId = nil
				}

				for _, costItem := range expenseNonstock.CostItems {

					nonstockId := costItem.NonstockID
					newExpenseNonstock := &entity.ExpenseNonstock{
						ExpenseId:             &expense.Id,
						ProjectFlockKandangId: projectFlockKandangId,
						KandangId:             kandangId,
						NonstockId:            &nonstockId,
						Qty:                   costItem.Quantity,
						Price:                 costItem.Price,
						Notes:                 costItem.Notes,
					}

					if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
						return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
					}
				}
			}
		}

		approvalAction := entity.ApprovalActionCreated
		createdByUint := uint(createdBy)
		if _, err := approvalSvcTx.CreateApproval(
			c.Context(),
			utils.ApprovalWorkflowExpense,
			uint(expense.Id),
			utils.ExpenseStepPengajuan,
			&approvalAction,
			createdByUint,
			nil); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to create initial approval")
		}

		if s.DocumentSvc != nil && len(req.Documents) > 0 {
			documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
			for idx, file := range req.Documents {
				documentFiles = append(documentFiles, commonSvc.DocumentFile{
					File:  file,
					Type:  string(utils.DocumentTypeExpense),
					Index: &idx,
				})
			}
			_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
				DocumentableType: string(utils.DocumentableTypeExpense),
				DocumentableID:   expense.Id,
				CreatedBy:        &createdByUint,
				Files:            documentFiles,
			})
			if err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
			}
		}

		return nil
	})

	if err != nil {
		if fiberErr, ok := err.(*fiber.Error); ok {
			return nil, fiberErr
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense")
	}

	responseDTO, err := s.GetOne(c, uint(expense.Id))
	if err != nil {
		return nil, err
	}
	s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil)
	return responseDTO, nil
}

func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*expenseDto.ExpenseDetailDTO, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
	); err != nil {
		return nil, err
	}

	latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
	}

	if latestApproval != nil {
		if latestApproval.StepNumber >= uint16(utils.ExpenseStepRealisasi) {
			currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
			return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot update expense at %s step. Must be before Realisasi step", currentStepName))
		}
	}

	updateBody := make(map[string]any)
	var requestedTransactionDate *time.Time

	if req.TransactionDate != nil {
		expenseDate, err := utils.ParseDateString(*req.TransactionDate)
		if err != nil {
			return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
		}
		updateBody["transaction_date"] = expenseDate
		requestedTransactionDate = &expenseDate
	}

	if req.Category != nil {
		updateBody["category"] = *req.Category
	}

	if req.SupplierID != nil {
		supplierID := uint(*req.SupplierID)
		supplierExistsFunc := func(ctx context.Context, id uint) (bool, error) {
			return commonRepo.Exists[entity.Supplier](ctx, s.SupplierRepo.DB(), id)
		}
		if err := commonSvc.EnsureRelations(c.Context(),
			commonSvc.RelationCheck{Name: "Supplier", ID: &supplierID, Exists: supplierExistsFunc},
		); err != nil {
			return nil, err
		}
		updateBody["supplier_id"] = *req.SupplierID
	}

	if req.Notes != nil {
		updateBody["notes"] = *req.Notes
	}

	if req.LocationID != nil {
		locationID := uint(*req.LocationID)
		updateBody["location_id"] = locationID
	}

	if len(updateBody) == 0 && req.ExpenseNonstocks == nil && len(req.Documents) == 0 {

		responseDTO, err := s.GetOne(c, id)
		if err != nil {
			return nil, err
		}
		return responseDTO, nil
	}

	var invalidationFromDate time.Time
	var invalidationFarmIDs []uint
	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {

		expenseRepoTx := repository.NewExpenseRepository(tx)
		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)

		currentExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return fiber.NewError(fiber.StatusNotFound, "Expense not found")
			}
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
		}

		if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
			return err
		}
		oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
		if resolveOldFarmErr != nil {
			s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr)
		}
		invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...)

		invalidationFromDate = currentExpense.TransactionDate
		if requestedTransactionDate != nil {
			invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate)
		}
		categoryChanged := false
		var newCategory string
		if req.Category != nil && *req.Category != currentExpense.Category {
			categoryChanged = true
			newCategory = *req.Category
		}

		if len(updateBody) > 0 {
			if err := expenseRepoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return fiber.NewError(fiber.StatusNotFound, "Expense not found")
				}
				return err
			}
		}

		if categoryChanged {
			if currentExpense.Category == string(utils.ExpenseCategoryBOP) && newCategory == string(utils.ExpenseCategoryNonBOP) {

				var existingExpenseNonstocks []entity.ExpenseNonstock
				if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks")
				}

				for _, ens := range existingExpenseNonstocks {
					updateData := map[string]interface{}{
						"project_flock_kandang_id": nil,
					}
					if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil {
						return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id to null")
					}
				}
			} else if currentExpense.Category == string(utils.ExpenseCategoryNonBOP) && newCategory == string(utils.ExpenseCategoryBOP) {

				var existingExpenseNonstocks []entity.ExpenseNonstock
				if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks")
				}

				projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
				for _, ens := range existingExpenseNonstocks {
					if ens.KandangId != nil {
						projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId))
						if err != nil {
							if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
								return err
							}
							if errors.Is(err, gorm.ErrRecordNotFound) {
								return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
							}
							return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
						}
						projectFlockKandangId := uint64(projectFlockKandang.Id)

						updateData := map[string]interface{}{
							"project_flock_kandang_id": projectFlockKandangId,
						}
						if err := expenseNonstockRepoTx.PatchOne(c.Context(), uint(ens.Id), updateData, nil); err != nil {
							return fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock kandang id")
						}
					}
				}
			}
		}

		if req.ExpenseNonstocks != nil {

			var existingExpenseNonstocks []entity.ExpenseNonstock
			if err := tx.Where("expense_id = ?", id).Find(&existingExpenseNonstocks).Error; err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense nonstocks for deletion")
			}

			for _, ens := range existingExpenseNonstocks {
				if err := expenseNonstockRepoTx.DeleteOne(c.Context(), uint(ens.Id)); err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete expense nonstock")
				}
			}

			updatedExpense, err := expenseRepoTx.GetByID(c.Context(), id, nil)
			if err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return fiber.NewError(fiber.StatusNotFound, "Expense not found")
				}
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to get updated expense")
			}

			for _, expenseNonstock := range *req.ExpenseNonstocks {
				var projectFlockKandangId *uint64
				var kandangId *uint64

				// Check if attaching to kandang
				if expenseNonstock.KandangID != nil {
					kandangId = expenseNonstock.KandangID

					if updatedExpense.Category == string(utils.ExpenseCategoryBOP) {
						// BOP with kandang: Get active project flock kandang
						projectFlockKandangRepoTx := projectFlockKandangRepo.NewProjectFlockKandangRepository(tx)
						projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*kandangId))
						if err != nil {
							if errors.Is(err, gorm.ErrRecordNotFound) {
								return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang")
							}
							return fiber.NewError(fiber.StatusInternalServerError, "Failed to find project flock kandang for this kandang")
						}
						id := uint64(projectFlockKandang.Id)
						projectFlockKandangId = &id
					}
					// NON-BOP: projectFlockKandangId stays nil
				}

				for _, costItem := range expenseNonstock.CostItems {

					nonstockId := uint(costItem.NonstockID)
					if err := commonSvc.EnsureRelations(c.Context(),
						commonSvc.RelationCheck{Name: "Nonstock", ID: &nonstockId, Exists: s.NonstockRepo.IdExists},
					); err != nil {
						return err
					}

					expenseId := uint64(id)
					newExpenseNonstock := &entity.ExpenseNonstock{
						ExpenseId:             &expenseId,
						ProjectFlockKandangId: projectFlockKandangId,
						KandangId:             kandangId,
						NonstockId:            &costItem.NonstockID,
						Qty:                   costItem.Quantity,
						Price:                 costItem.Price,
						Notes:                 costItem.Notes,
					}

					if err := expenseNonstockRepoTx.CreateOne(c.Context(), newExpenseNonstock, nil); err != nil {
						return fiber.NewError(fiber.StatusInternalServerError, "Failed to create expense cost item")
					}
				}
			}
		}

		actorID, err := middleware.ActorIDFromContext(c)
		if err != nil {
			return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
		}

		if *latestApproval.Action != entity.ApprovalActionUpdated && latestApproval.StepNumber > uint16(utils.ExpenseStepPengajuan) {

			approvalAction := entity.ApprovalActionUpdated

			previousStep := approvalutils.ApprovalStep(latestApproval.StepNumber) - 1

			if previousStep < utils.ExpenseStepPengajuan {
				previousStep = utils.ExpenseStepPengajuan
			}

			if _, err := approvalSvcTx.CreateApproval(
				c.Context(),
				utils.ApprovalWorkflowExpense,
				id,
				previousStep,
				&approvalAction,
				actorID,
				nil); err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step")
			}

		}

		if s.DocumentSvc != nil && len(req.Documents) > 0 {
			documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
			for idx, file := range req.Documents {
				documentFiles = append(documentFiles, commonSvc.DocumentFile{
					File:  file,
					Type:  string(utils.DocumentTypeExpense),
					Index: &idx,
				})
			}
			_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
				DocumentableType: string(utils.DocumentableTypeExpense),
				DocumentableID:   uint64(id),
				CreatedBy:        &actorID,
				Files:            documentFiles,
			})
			if err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense documents")
			}
		}

		newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
		if resolveNewFarmErr != nil {
			s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr)
		}
		invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...)

		return nil
	})

	if err != nil {
		if fiberErr, ok := err.(*fiber.Error); ok {
			return nil, fiberErr
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update expense")
	}

	responseDTO, err := s.GetOne(c, id)
	if err != nil {
		return nil, err
	}
	s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate)
	return responseDTO, nil
}

func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
	idUint := uint(id)

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &idUint, Exists: s.Repository.IdExists},
	); err != nil {
		return err
	}
	expense, err := s.Repository.GetByID(c.Context(), idUint, func(db *gorm.DB) *gorm.DB {
		return db.Preload("Nonstocks")
	})
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
			return fiber.NewError(fiber.StatusNotFound, "Expense not found")
		}
		s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err)
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
	}

	if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
		return err
	}
	farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint)
	if resolveFarmErr != nil {
		s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr)
	}
	if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
			return fiber.NewError(fiber.StatusNotFound, "Expense not found")
		}
		s.Log.Errorf("Failed to delete expense for ID %d: %+v", id, err)
		return err
	}
	s.Log.Infof("Successfully deleted expense with ID %d", id)
	invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
	s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate)
	return nil
}

func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
	); err != nil {
		return nil, err
	}

	realizationDate, err := utils.ParseDateString(req.RealizationDate)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format")
	}

	expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
		return db.Preload("Nonstocks")
	})
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
	}

	if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
		return nil, err
	}

	actorID, err := middleware.ActorIDFromContext(c)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
	}

	if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
		expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
		expenseRepoTx := repository.NewExpenseRepository(tx)

		for _, realizationItem := range req.Realizations {

			expenseNonstockID := realizationItem.ExpenseNonstockID

			if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil {
				return err
			}

			_, err = realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
			if err == nil {
				return fiber.NewError(fiber.StatusBadRequest, "Realization already exists for this expense nonstock")
			} else if !errors.Is(err, gorm.ErrRecordNotFound) {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
			}

			realization := &entity.ExpenseRealization{
				ExpenseNonstockId: &expenseNonstockID,
				Qty:               realizationItem.Qty,
				Price:             realizationItem.Price,
				Notes:             "",
			}

			if realizationItem.Notes != nil {
				realization.Notes = *realizationItem.Notes
			}

			if err := realizationRepoTx.CreateOne(c.Context(), realization, nil); err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
			}
		}

		if s.DocumentSvc != nil && len(req.Documents) > 0 {
			documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
			for idx, file := range req.Documents {
				documentFiles = append(documentFiles, commonSvc.DocumentFile{
					File:  file,
					Type:  string(utils.DocumentTypeExpenseRealization),
					Index: &idx,
				})
			}
			_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
				DocumentableType: string(utils.DocumentableTypeExpenseRealization),
				DocumentableID:   uint64(expenseID),
				CreatedBy:        &actorID,
				Files:            documentFiles,
			})
			if err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
			}
		}

		if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{
			"realization_date": realizationDate,
		}, nil); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
		}

		approvalAction := entity.ApprovalActionCreated
		if _, err := approvalSvc.CreateApproval(
			c.Context(),
			utils.ApprovalWorkflowExpense,
			expenseID,
			utils.ExpenseStepRealisasi,
			&approvalAction,
			actorID,
			nil,
		); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
		}

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

	responseDTO, err := s.GetOne(c, expenseID)
	if err != nil {
		return nil, err
	}
	invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate)
	s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
	return responseDTO, nil
}

func (s *expenseService) BulkApproveToStatus(c *fiber.Ctx, req *validation.BulkApprovalRequest, target approvalutils.ApprovalStep) ([]expenseDto.ExpenseDetailDTO, error) {
	if err := s.Validate.Struct(req); err != nil {
		return nil, err
	}

	approvableIDs := utils.UniqueUintSlice(req.ApprovableIds)
	if len(approvableIDs) == 0 {
		return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id")
	}

	actorID, err := middleware.ActorIDFromContext(c)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
	}

	var realizationDate time.Time
	if req.RequiresDate(target) {
		realizationDate, err = utils.ParseDateString(req.Date)
		if err != nil {
			return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid date format")
		}
	}

	invalidateFromDateByExpenseID := make(map[uint]time.Time, len(approvableIDs))

	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		expenseRepoTx := repository.NewExpenseRepository(tx)

		for _, id := range approvableIDs {
			if err := commonSvc.EnsureRelations(c.Context(),
				commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: expenseRepoTx.IdExists},
			); err != nil {
				return err
			}

			expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
				return db.Preload("Nonstocks")
			})
			if err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return fiber.NewError(fiber.StatusNotFound, "Expense not found")
				}
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
			}

			latestApproval, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
			if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
			}
			if latestApproval == nil {
				return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for Expense %d", id))
			}
			if latestApproval.Action != nil && *latestApproval.Action == entity.ApprovalActionRejected {
				return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is rejected and cannot be bulk approved", id))
			}

			currentStep := approvalutils.ApprovalStep(latestApproval.StepNumber)
			if currentStep >= target {
				currentStepName := utils.ExpenseApprovalSteps[currentStep]
				targetStepName := utils.ExpenseApprovalSteps[target]
				if currentStep == target {
					return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already at %s step", id, targetStepName))
				}
				return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Expense %d is already beyond %s step (current step: %s)", id, targetStepName, currentStepName))
			}

			invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)

			for step := currentStep + 1; step <= target; step++ {
				if step == utils.ExpenseStepRealisasi {
					if err := s.createRealizationFromExpenseLines(c.Context(), tx, expense, realizationDate, actorID, req.Notes); err != nil {
						return err
					}
					invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate, realizationDate)
					break
				}

				approvalAction := entity.ApprovalActionApproved
				if _, err := approvalSvcTx.CreateApproval(
					c.Context(),
					utils.ApprovalWorkflowExpense,
					id,
					step,
					&approvalAction,
					actorID,
					req.Notes,
				); err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
				}

				if step == utils.ExpenseStepFinance && expense.PoNumber == "" {
					poNumber, err := s.generatePoNumber(tx, id)
					if err != nil {
						return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
					}
					if err := expenseRepoTx.PatchOne(c.Context(), id, map[string]interface{}{"po_number": poNumber}, nil); err != nil {
						return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
					}
					expense.PoNumber = poNumber
				}
			}

			invalidateFromDateByExpenseID[id] = invalidateFromDate
		}

		return nil
	})
	if err != nil {
		if fiberErr, ok := err.(*fiber.Error); ok {
			return nil, fiberErr
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to bulk approve expenses")
	}

	results := make([]expenseDto.ExpenseDetailDTO, 0, len(approvableIDs))
	for _, id := range approvableIDs {
		responseDTO, err := s.GetOne(c, id)
		if err != nil {
			return nil, err
		}
		results = append(results, *responseDTO)
	}

	for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
		s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
	}

	return results, nil
}

func (s *expenseService) createRealizationFromExpenseLines(
	ctx context.Context,
	tx *gorm.DB,
	expense *entity.Expense,
	realizationDate time.Time,
	actorID uint,
	notes *string,
) error {
	if expense == nil {
		return fiber.NewError(fiber.StatusBadRequest, "Expense not found")
	}
	if tx == nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Transaction is required")
	}
	if err := s.ensureProjectFlockNotClosedForExpense(ctx, expense); err != nil {
		return err
	}

	realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
	expenseRepoTx := repository.NewExpenseRepository(tx)
	approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))

	for _, expenseNonstock := range expense.Nonstocks {
		expenseNonstockID := expenseNonstock.Id

		_, err := realizationRepoTx.GetByExpenseNonstockID(ctx, expenseNonstockID)
		if err == nil {
			return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Realization already exists for expense nonstock %d", expenseNonstockID))
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing realization")
		}

		realization := &entity.ExpenseRealization{
			ExpenseNonstockId: &expenseNonstockID,
			Qty:               expenseNonstock.Qty,
			Price:             expenseNonstock.Price,
			Notes:             expenseNonstock.Notes,
		}

		if err := realizationRepoTx.CreateOne(ctx, realization, nil); err != nil {
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization")
		}
	}

	if err := expenseRepoTx.PatchOne(ctx, uint(expense.Id), map[string]interface{}{
		"realization_date": realizationDate,
	}, nil); err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
	}

	approvalAction := entity.ApprovalActionCreated
	if _, err := approvalSvc.CreateApproval(
		ctx,
		utils.ApprovalWorkflowExpense,
		uint(expense.Id),
		utils.ExpenseStepRealisasi,
		&approvalAction,
		actorID,
		notes,
	); err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
	}

	expense.RealizationDate = realizationDate
	return nil
}

func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) {

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
	); err != nil {
		return nil, err
	}

	actorID, err := middleware.ActorIDFromContext(c)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
	}

	latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
	}
	if latestApproval == nil {
		return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.")
	}

	if latestApproval.StepNumber != uint16(utils.ExpenseStepRealisasi) {
		currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
		return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot complete expense at %s step. Must be at Realisasi step", currentStepName))
	}

	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {

		approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		approvalAction := entity.ApprovalActionApproved

		if _, err := approvalSvc.CreateApproval(
			c.Context(),
			utils.ApprovalWorkflowExpense,
			id,
			utils.ExpenseStepSelesai,
			&approvalAction,
			actorID,
			notes); err != nil {

			return fiber.NewError(fiber.StatusInternalServerError, "Failed to complete expense")
		}

		return nil
	})

	if err != nil {
		return nil, err
	}

	responseDTO, err := s.GetOne(c, id)
	if err != nil {
		return nil, err
	}
	expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil)
	if expenseErr != nil {
		s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr)
	} else {
		invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
		s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil)
	}
	return responseDTO, nil
}

func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) {
	if err := s.Validate.Struct(req); err != nil {

		return nil, err
	}

	if err := commonSvc.EnsureRelations(c.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
	); err != nil {
		return nil, err
	}
	expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB {
		return db.Preload("Nonstocks")
	})
	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found")
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense")
	}

	if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
		return nil, err
	}
	invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
	if req.RealizationDate != nil {
		if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil {
			invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate)
		}
	}
	latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
	}

	if latestApproval != nil && (latestApproval.StepNumber < uint16(utils.ExpenseStepRealisasi)) {
		currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
		return nil, fiber.NewError(fiber.StatusBadRequest,
			fmt.Sprintf("tidak bisa update realisasi pada step %s. Harus pada step Realisasi atau selesai", currentStepName))
	}

	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {

		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		realizationRepoTx := repository.NewExpenseRealizationRepository(tx)
		expenseNonstockRepoTx := repository.NewExpenseNonstockRepository(tx)
		expenseRepoTx := repository.NewExpenseRepository(tx)

		// Check if only updating documents
		updateDataOnly := req.Realizations == nil && len(req.Documents) > 0

		if req.Realizations != nil {
			for _, realizationItem := range *req.Realizations {

				expenseNonstockID := realizationItem.ExpenseNonstockID

				if err := s.validateExpenseNonstockRelation(c, expenseNonstockRepoTx, expenseID, expenseNonstockID); err != nil {
					return err
				}

				existingRealization, err := realizationRepoTx.GetByExpenseNonstockID(c.Context(), uint64(expenseNonstockID))
				if err != nil {
					if errors.Is(err, gorm.ErrRecordNotFound) {

						return fiber.NewError(fiber.StatusNotFound, "Realization not found for this expense nonstock")
					}

					return fiber.NewError(fiber.StatusInternalServerError, "Failed to get existing realization")
				}

				updateData := map[string]interface{}{
					"qty":   realizationItem.Qty,
					"price": realizationItem.Price,
				}

				if realizationItem.Notes != nil {
					updateData["notes"] = *realizationItem.Notes
				}

				if err := realizationRepoTx.PatchOne(c.Context(), uint(existingRealization.Id), updateData, nil); err != nil {
					s.Log.Errorf("Failed to update realization: %+v", err)
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization")
				}
			}

		}

		if req.RealizationDate != nil {
			if err := expenseRepoTx.PatchOne(c.Context(), expenseID, map[string]interface{}{"realization_date": *req.RealizationDate}, nil); err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to update realization date")
			}
		}

		if s.DocumentSvc != nil && len(req.Documents) > 0 {
			documentFiles := make([]commonSvc.DocumentFile, 0, len(req.Documents))
			for idx, file := range req.Documents {
				documentFiles = append(documentFiles, commonSvc.DocumentFile{
					File:  file,
					Type:  string(utils.DocumentTypeExpenseRealization),
					Index: &idx,
				})
			}
			actorID := uint(1) // TODO: replace with authenticated user id
			_, err := s.DocumentSvc.UploadDocuments(c.Context(), commonSvc.DocumentUploadRequest{
				DocumentableType: string(utils.DocumentableTypeExpenseRealization),
				DocumentableID:   uint64(expenseID),
				CreatedBy:        &actorID,
				Files:            documentFiles,
			})
			if err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to upload expense realization documents")
			}
		}

		if !updateDataOnly && *latestApproval.Action == entity.ApprovalActionUpdated {
			actorID := uint(1) // TODO: replace with authenticated user id
			approvalAction := entity.ApprovalActionUpdated
			if _, err := approvalSvcTx.CreateApproval(
				c.Context(),
				utils.ApprovalWorkflowExpense,
				expenseID,
				utils.ExpenseStepRealisasi,
				&approvalAction,
				actorID,
				nil); err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to create realization approval")
			}
		}

		return nil
	})
	if err != nil {
		if fiberErr, ok := err.(*fiber.Error); ok {
			return nil, fiberErr
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "gagal update realisasi expense")
	}

	responseDTO, err := s.GetOne(c, expenseID)
	if err != nil {
		return nil, err
	}
	s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
	return responseDTO, nil
}

func (s *expenseService) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error {

	if err := commonSvc.EnsureRelations(ctx.Context(),
		commonSvc.RelationCheck{Name: "Expense", ID: &expenseID, Exists: s.Repository.IdExists},
	); err != nil {
		return err
	}

	if s.DocumentSvc == nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Document service not available")
	}

	// Verify document exists and belongs to the expense
	var documentableType string
	if isRealization {
		documentableType = string(utils.DocumentableTypeExpenseRealization)
	} else {
		documentableType = string(utils.DocumentableTypeExpense)
	}

	documents, err := s.DocumentSvc.ListByTarget(ctx.Context(), documentableType, uint64(expenseID))
	if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to retrieve documents")
	}

	documentFound := false
	var documentIDsToDelete []uint
	for _, doc := range documents {
		if uint64(doc.Id) == documentID {
			documentFound = true
			documentIDsToDelete = append(documentIDsToDelete, doc.Id)
			break
		}
	}

	if !documentFound {
		return fiber.NewError(fiber.StatusNotFound, "Document not found")
	}

	// Delete document from database and storage
	if err := s.DocumentSvc.DeleteDocuments(ctx.Context(), documentIDsToDelete, true); err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete document")
	}

	return nil
}

func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) {
	if len(req.ApprovableIds) == 0 {
		return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided")
	}

	actorID, err := middleware.ActorIDFromContext(c)
	if err != nil {
		return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
	}

	var results []expenseDto.ExpenseDetailDTO
	invalidateFromDateByExpenseID := make(map[uint]time.Time)

	err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {

		approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx))
		expenseRepoTx := repository.NewExpenseRepository(tx)

		for _, id := range req.ApprovableIds {
			if err := commonSvc.EnsureRelations(c.Context(),
				commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists},
			); err != nil {
				return err
			}
			expenseForInvalidation, err := expenseRepoTx.GetByID(c.Context(), id, nil)
			if err != nil {
				if errors.Is(err, gorm.ErrRecordNotFound) {
					return fiber.NewError(fiber.StatusNotFound, "Expense not found")
				}
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
			}
			invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC(
				expenseForInvalidation.TransactionDate,
				expenseForInvalidation.RealizationDate,
			)

			latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
			if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
			}

			var stepNumber approvalutils.ApprovalStep
			if approvalType == "head-area" {

				stepNumber = utils.ExpenseStepHeadArea
				if latestApproval.StepNumber != uint16(utils.ExpenseStepPengajuan) {
					currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
					return fiber.NewError(fiber.StatusBadRequest,
						fmt.Sprintf("Cannot process at Head Area step. Latest approval is at %s step. Expected previous step: Pengajuan", currentStepName))
				}
			} else if approvalType == "unit-vice-president" {

				stepNumber = utils.ExpenseStepUnitVicePresident
				if latestApproval.StepNumber != uint16(utils.ExpenseStepHeadArea) {
					currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
					return fiber.NewError(fiber.StatusBadRequest,
						fmt.Sprintf("Cannot process at Unit Vice President step. Latest approval is at %s step. Expected previous step: Head Area", currentStepName))
				}

			} else if approvalType == "finance" {

				stepNumber = utils.ExpenseStepFinance
				if latestApproval.StepNumber != uint16(utils.ExpenseStepUnitVicePresident) {
					currentStepName := utils.ExpenseApprovalSteps[approvalutils.ApprovalStep(latestApproval.StepNumber)]
					return fiber.NewError(fiber.StatusBadRequest,
						fmt.Sprintf("Cannot process at Finance step. Latest approval is at %s step. Expected previous step: Unit Vice President", currentStepName))
				}
			} else {
				return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid approval type: %v", approvalType))
			}

			var approvalAction entity.ApprovalAction
			if req.Action == "APPROVED" {
				approvalAction = entity.ApprovalActionApproved
			} else if req.Action == "REJECTED" {
				approvalAction = entity.ApprovalActionRejected
			} else {
				return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action")
			}
			if approvalAction == entity.ApprovalActionApproved {
				expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
					return db.Preload("Nonstocks")
				})
				if err != nil {
					if errors.Is(err, gorm.ErrRecordNotFound) {
						return fiber.NewError(fiber.StatusNotFound, "Expense not found")
					}
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
				}

				if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
					return err
				}
			}

			if _, err := approvalSvcTx.CreateApproval(
				c.Context(),
				utils.ApprovalWorkflowExpense,
				id,
				stepNumber,
				&approvalAction,
				actorID,
				req.Notes); err != nil {
				return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval")
			}

			if stepNumber == utils.ExpenseStepFinance && req.Action == "APPROVED" {
				poNumber, err := s.generatePoNumber(tx, id)
				if err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate PO number")
				}

				updateData := map[string]interface{}{
					"po_number": poNumber,
				}
				if err := expenseRepoTx.PatchOne(c.Context(), id, updateData, nil); err != nil {
					return fiber.NewError(fiber.StatusInternalServerError, "Failed to update PO number")
				}
			}

			responseDTO, err := s.GetOne(c, id)
			if err != nil {
				return err
			}
			results = append(results, *responseDTO)
		}

		return nil
	})

	if err != nil {
		if fiberErr, ok := err.(*fiber.Error); ok {
			return nil, fiberErr
		}
		return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses")
	}
	for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
		s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
	}

	return results, nil
}

func (s *expenseService) invalidateDepreciationSnapshotsByExpense(
	ctx context.Context,
	tx *gorm.DB,
	expenseID uint,
	fromDate time.Time,
	fallbackFarmIDs []uint,
) {
	targetDB := s.Repository.DB()
	if tx != nil {
		targetDB = tx
	}

	farmIDs := append([]uint{}, fallbackFarmIDs...)
	if expenseID != 0 {
		resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID)
		if err != nil {
			s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err)
		} else {
			farmIDs = append(farmIDs, resolvedFarmIDs...)
		}
	}
	s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate)
}

func (s *expenseService) invalidateDepreciationSnapshots(
	ctx context.Context,
	tx *gorm.DB,
	farmIDs []uint,
	fromDate time.Time,
) {
	if fromDate.IsZero() {
		return
	}

	targetDB := s.Repository.DB()
	if tx != nil {
		targetDB = tx
	}
	farmIDs = utils.UniqueUintSlice(farmIDs)
	if len(farmIDs) == 0 {
		if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
			s.Log.Warnf(
				"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
				fromDate.Format("2006-01-02"),
				err,
			)
		}
		return
	}

	if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
		s.Log.Warnf(
			"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
			farmIDs,
			fromDate.Format("2006-01-02"),
			err,
		)
	}
}

func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {

	expenseRepoTx := repository.NewExpenseRepository(ctx)
	expense, err := expenseRepoTx.GetByID(context.Background(), uint(expenseID), nil)
	if err != nil {
		return "", err
	}

	poNum := fmt.Sprintf("PO-%s", expense.ReferenceNumber)
	return poNum, nil
}

func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expenseNonstockRepoTx repository.ExpenseNonstockRepository, expenseID uint, expenseNonstockID uint64) error {
	belongsToExpense, err := expenseNonstockRepoTx.GetByExpenseID(ctx.Context(), uint64(expenseID), expenseNonstockID)
	if err != nil {
		return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate ExpenseNonstock relation")
	}
	if !belongsToExpense {
		return fiber.NewError(fiber.StatusNotFound, "ExpenseNonstock not found or does not belong to this expense")
	}
	return nil
}

func (s *expenseService) ensureProjectFlockNotClosedForExpense(
	ctx context.Context,
	expense *entity.Expense,
) error {
	// Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa
	if s.ProjectFlockKandangRepo == nil || expense == nil {
		return nil
	}

	seen := make(map[uint]struct{})

	for _, ens := range expense.Nonstocks {
		// Field ini pointer, bisa nil
		if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 {
			continue
		}

		pfkID := uint(*ens.ProjectFlockKandangId)
		if _, ok := seen[pfkID]; ok {
			continue
		}
		seen[pfkID] = struct{}{}

		pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID)
		if err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return fiber.NewError(
					fiber.StatusBadRequest,
					fmt.Sprintf("Project flock %d tidak ditemukan", pfkID),
				)
			}
			s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err)
			return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
		}
		// ❗ RULE: kalau ClosedAt tidak nil → project sudah closing
		if pfk.ClosedAt != nil {
			return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
		}
	}

	return nil
}
