package service

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"gorm.io/gorm"
	"gorm.io/gorm/clause"

	basedomain "wucher/internal/domain/base"
	beforeflightinspection "wucher/internal/domain/before_flight_inspection"
	filemanager "wucher/internal/domain/file_manager"
	"wucher/internal/domain/flight"
	flightinspection "wucher/internal/domain/flight_inspection"
	flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
	flightprepcheck "wucher/internal/domain/flight_prep_check"
	helicopterfile "wucher/internal/domain/helicopter_file"
	otherpersondomain "wucher/internal/domain/other_person"
	reserveac "wucher/internal/domain/reserve_ac"
	takeoverdomain "wucher/internal/domain/takeover"
	"wucher/internal/shared/pkg/missioncode"
	"wucher/internal/shared/pkg/reserveacutil"
	"wucher/internal/shared/pkg/util"
	"wucher/internal/shared/pkg/uuidv7"
	requestdto "wucher/internal/transport/http/dto/request"
)

// checklistTemplateFileRepository loads the helicopter files that make up an inspection
// checklist section. The concrete implementation (mysql.TakeoverRepository) is injected so
// the service does not depend on the repository package directly. The transaction handle is
// passed through so the read runs within the caller's ongoing transaction.
type checklistTemplateFileRepository interface {
	ListChecklistTemplateFiles(tx *gorm.DB, helicopterID []byte, section string) ([]helicopterfile.HelicopterFile, error)
	ListActiveEditedTemplateUUIDs(tx *gorm.DB, takeoverID []byte) ([][]byte, error)
}

type TakeoverService struct {
	db             *gorm.DB
	checklistFiles checklistTemplateFileRepository
}

type TakeoverCreateInput struct {
	BaseID          []byte
	HasBaseID       bool
	DutyDate        time.Time
	HasDutyDate     bool
	Notes           *string
	RosterDetail    *requestdto.DutyRosterDetailsInput
	HasRosterDetail bool
	HelicopterID    []byte
	HasHelicopterID bool
	Inspection      *requestdto.ReserveAcInspectionCreateAttributes
	HasInspection   bool
	ActorUserID     []byte
}

type TakeoverCreateResult struct {
	TakeoverAcID       []byte
	DutyRosterID       []byte
	FlightID           []byte
	ReserveAcID        []byte
	FlightInspectionID []byte
	ShiftStart         string
	ShiftEnd           string
}

type takeoverExisting struct {
	Flight   *flight.Flight
	Takeover *takeoverdomain.TakeoverAc
	Reserve  *reserveac.ReserveAc
	Crews    []takeoverdomain.TakeoverRosterCrew
	Others   []takeoverdomain.TakeoverOtherPerson
	Before   *beforeflightinspection.BeforeFlightInspection
	Prep     *flightprepcheck.FlightPrepCheck
}

func NewTakeoverService(db *gorm.DB) *TakeoverService {
	return &TakeoverService{db: db}
}

// WithChecklistTemplateRepository injects the repository used to load checklist template
// files. It must be wired at startup so DB access stays in the repository layer.
func (s *TakeoverService) WithChecklistTemplateRepository(repo checklistTemplateFileRepository) *TakeoverService {
	if s != nil {
		s.checklistFiles = repo
	}
	return s
}

func logTakeoverServiceStepError(step string, err error) {
	if err == nil {
		return
	}
	slog.Error("takeover service step failed",
		"step", step,
		"error", err.Error(),
		"error_type", fmt.Sprintf("%T", err),
	)
}

func (s *TakeoverService) Create(ctx context.Context, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
	if s == nil || s.db == nil {
		return nil, errors.New("takeover service is not configured")
	}
	if !in.HasDutyDate {
		return nil, errors.New("duty_date is required")
	}
	if in.HasBaseID && len(in.BaseID) != 16 {
		return nil, errors.New("base_id must be a valid UUID")
	}
	if in.HasHelicopterID && len(in.HelicopterID) != 16 {
		return nil, errors.New("helicopter_id must be a valid UUID")
	}
	if !in.HasBaseID && !in.HasRosterDetail {
		return nil, errors.New("base_id or roster_detail is required")
	}

	return s.createInTx(ctx, in)
}

func (s *TakeoverService) createInTx(ctx context.Context, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
	result := &TakeoverCreateResult{}
	err := s.withRetryTx(ctx, func(tx *gorm.DB) error {
		if in.HasHelicopterID {
			booked, err := isHelicopterBookedTx(tx, in.HelicopterID, nil)
			if err != nil {
				return err
			}
			if booked {
				return errors.New("helicopter is already booked")
			}
		}

		takeoverAc := &takeoverdomain.TakeoverAc{
			Notes:     in.Notes,
			CreatedBy: in.ActorUserID,
			UpdatedBy: in.ActorUserID,
		}
		if in.HasBaseID {
			shiftStart, shiftEnd, err := ResolveBaseShiftRangeTx(tx, in.BaseID, in.DutyDate)
			if err != nil {
				return err
			}
			result.ShiftStart = shiftStart
			result.ShiftEnd = shiftEnd
			if err := ensureBaseExistsTx(tx, in.BaseID); err != nil {
				return err
			}
			takeoverAc.BaseID = append([]byte(nil), in.BaseID...)
		}
		if err := tx.Create(takeoverAc).Error; err != nil {
			return err
		}
		result.TakeoverAcID = append([]byte(nil), takeoverAc.ID...)

		if in.HasRosterDetail {
			if err := s.upsertTakeoverRosterRowsTx(tx, takeoverAc.ID, in); err != nil {
				return err
			}
		}

		existing := &takeoverExisting{}
		if err := s.upsertTakeoverReserveTx(tx, takeoverAc.ID, in.BaseID, existing, in); err != nil {
			return err
		}

		now := time.Now().UTC()
		prefix := missioncode.BuildPrefix(now)
		latest, err := getLatestMissionCodeByPrefixTx(tx, prefix)
		if err != nil {
			return err
		}
		nextCode := missioncode.NextFromLatest(prefix, latest)
		flightRow := &flight.Flight{
			Status:       "draft",
			MissionCode:  &nextCode,
			Date:         dateOnlyUTC(in.DutyDate),
			TakeoverAcID: append([]byte(nil), takeoverAc.ID...),
			CreatedBy:    in.ActorUserID,
			UpdatedBy:    in.ActorUserID,
		}
		if err := tx.Create(flightRow).Error; err != nil {
			return err
		}
		result.FlightID = append([]byte(nil), flightRow.ID...)
		if existing.Reserve != nil {
			result.ReserveAcID = append([]byte(nil), existing.Reserve.ID...)
			result.FlightInspectionID = append([]byte(nil), existing.Reserve.InspectionID...)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	return result, nil
}

func (s *TakeoverService) Replace(ctx context.Context, existingFlightID []byte, in TakeoverCreateInput) (*TakeoverCreateResult, error) {
	if len(existingFlightID) != 16 {
		return nil, errors.New("id is invalid UUID")
	}
	result := &TakeoverCreateResult{}
	err := s.withRetryTx(ctx, func(tx *gorm.DB) error {
		existing, err := loadTakeoverExistingTx(tx, existingFlightID)
		if err != nil {
			logTakeoverServiceStepError("Replace.loadTakeoverExistingTx", err)
			return err
		}
		if existing == nil || existing.Flight == nil {
			return gorm.ErrRecordNotFound
		}

		targetBaseID := []byte(nil)
		if existing.Reserve != nil && len(existing.Reserve.BaseID) > 0 {
			targetBaseID = append([]byte(nil), existing.Reserve.BaseID...)
		} else if existing.Takeover != nil && len(existing.Takeover.BaseID) > 0 {
			targetBaseID = append([]byte(nil), existing.Takeover.BaseID...)
		}
		if in.HasBaseID {
			if len(in.BaseID) != 16 {
				return errors.New("base_id must be a valid UUID")
			}
			if err := ensureBaseExistsTx(tx, in.BaseID); err != nil {
				logTakeoverServiceStepError("Replace.ensureBaseExistsTx", err)
				return err
			}
			targetBaseID = append([]byte(nil), in.BaseID...)
		}

		effectiveDutyDate := existing.Flight.Date
		if in.HasDutyDate {
			effectiveDutyDate = dateOnlyUTC(in.DutyDate)
		}
		if len(targetBaseID) > 0 {
			shiftStart, shiftEnd, err := ResolveBaseShiftRangeTx(tx, targetBaseID, effectiveDutyDate)
			if err != nil {
				logTakeoverServiceStepError("Replace.ResolveBaseShiftRangeTx", err)
				return err
			}
			result.ShiftStart = shiftStart
			result.ShiftEnd = shiftEnd
		}

		takeoverID := existing.Flight.TakeoverAcID
		if len(takeoverID) != 16 {
			return gorm.ErrRecordNotFound
		}

		currentHelicopterID := []byte(nil)
		if existing.Reserve != nil && len(existing.Reserve.AircraftID) == 16 {
			currentHelicopterID = append([]byte(nil), existing.Reserve.AircraftID...)
		}
		if in.HasHelicopterID && len(in.HelicopterID) == 16 && !bytes.Equal(in.HelicopterID, currentHelicopterID) {
			booked, err := isHelicopterBookedTx(tx, in.HelicopterID, takeoverID)
			if err != nil {
				logTakeoverServiceStepError("Replace.isHelicopterBookedTx", err)
				return err
			}
			if booked {
				return errors.New("helicopter is already booked")
			}
		}

		takeoverUpdates := map[string]any{"base_id": targetBaseID, "updated_by": in.ActorUserID}
		if in.Notes != nil {
			takeoverUpdates["notes"] = *in.Notes
		}
		if err := tx.Model(&takeoverdomain.TakeoverAc{}).
			Where("id = ? AND deleted_at IS NULL", takeoverID).
			Updates(takeoverUpdates).Error; err != nil {
			logTakeoverServiceStepError("Replace.updateTakeover", err)
			return err
		}

		if err := s.upsertTakeoverReserveTx(tx, takeoverID, targetBaseID, existing, in); err != nil {
			logTakeoverServiceStepError("Replace.upsertTakeoverReserveTx", err)
			return err
		}

		targetFlightDate := dateOnlyUTC(in.DutyDate)
		if in.HasDutyDate && !existing.Flight.Date.Equal(targetFlightDate) {
			existing.Flight.Date = targetFlightDate
			if err := tx.Save(existing.Flight).Error; err != nil {
				logTakeoverServiceStepError("Replace.saveFlightDate", err)
				return err
			}
		}

		if in.HasRosterDetail {
			if err := s.upsertTakeoverRosterRowsTx(tx, takeoverID, in); err != nil {
				logTakeoverServiceStepError("Replace.upsertTakeoverRosterRowsTx", err)
				return err
			}
		}

		if existing.Reserve != nil {
			result.ReserveAcID = append([]byte(nil), existing.Reserve.ID...)
			result.FlightInspectionID = append([]byte(nil), existing.Reserve.InspectionID...)
		}
		result.TakeoverAcID = append([]byte(nil), takeoverID...)
		result.FlightID = append([]byte(nil), existing.Flight.ID...)
		return nil
	})
	if err != nil {
		return nil, err
	}
	return result, nil
}

func (s *TakeoverService) GetByID(ctx context.Context, id []byte) (*takeoverdomain.TakeoverAc, error) {
	if s == nil || s.db == nil {
		return nil, errors.New("takeover service is not configured")
	}
	if len(id) != 16 {
		return nil, errors.New("id is invalid UUID")
	}
	var row takeoverdomain.TakeoverAc
	err := s.db.WithContext(ctx).
		Preload("Base").
		Preload("ReserveAc").
		Preload("ReserveAc.Aircraft").
		Preload("ReserveAc.Inspection").
		Preload("RosterCrews").
		Preload("RosterCrews.User").
		Preload("OtherPeople").
		Preload("Files").
		Preload("Files.FileAttachment").
		Preload("Files.FileAttachment.File").
		Where("id = ? AND deleted_at IS NULL", id).
		First(&row).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, nil
	}
	return &row, err
}

func (s *TakeoverService) List(ctx context.Context, limit, offset int) ([]takeoverdomain.TakeoverAc, int64, error) {
	if s == nil || s.db == nil {
		return nil, 0, errors.New("takeover service is not configured")
	}
	rows := make([]takeoverdomain.TakeoverAc, 0)
	var total int64
	base := s.db.WithContext(ctx).
		Model(&takeoverdomain.TakeoverAc{}).
		Preload("Base").
		Preload("ReserveAc").
		Preload("ReserveAc.Aircraft").
		Preload("ReserveAc.Inspection").
		Preload("RosterCrews").
		Preload("OtherPeople").
		Preload("Files").
		Preload("Files.FileAttachment").
		Preload("Files.FileAttachment.File").
		Where("deleted_at IS NULL")
	if err := base.Count(&total).Error; err != nil {
		return nil, 0, err
	}
	q := base.Order("created_at DESC")
	if limit > 0 {
		q = q.Limit(limit).Offset(offset)
	}
	if err := q.Find(&rows).Error; err != nil {
		return nil, 0, err
	}
	return rows, total, nil
}

func (s *TakeoverService) AttachFile(ctx context.Context, takeoverID []byte, fileAttachmentID []byte) error {
	if s == nil || s.db == nil {
		return errors.New("takeover service is not configured")
	}
	if len(takeoverID) != 16 {
		return errors.New("takeover_id must be a valid UUID")
	}
	if len(fileAttachmentID) != 16 {
		return errors.New("file_attachment_id must be a valid UUID")
	}

	return s.withRetryTx(ctx, func(tx *gorm.DB) error {
		return attachTakeoverFilesTx(tx, takeoverID, [][]byte{fileAttachmentID})
	})
}

func (s *TakeoverService) DetachFile(ctx context.Context, takeoverID []byte, fileAttachmentID []byte) error {
	if s == nil || s.db == nil {
		return errors.New("takeover service is not configured")
	}
	if len(takeoverID) != 16 {
		return errors.New("takeover_id must be a valid UUID")
	}
	if len(fileAttachmentID) != 16 {
		return errors.New("file_attachment_id must be a valid UUID")
	}

	return s.withRetryTx(ctx, func(tx *gorm.DB) error {
		var takeoverRow takeoverdomain.TakeoverAc
		if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverID).Take(&takeoverRow).Error; err != nil {
			return err
		}
		res := tx.Where("takeover_id = ? AND file_attachment_id = ?", takeoverID, fileAttachmentID).Delete(&takeoverdomain.TakeoverFile{})
		if res.Error != nil {
			return res.Error
		}
		if res.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}
		return nil
	})
}

func attachTakeoverFilesTx(tx *gorm.DB, takeoverID []byte, fileAttachmentIDs [][]byte) error {
	if len(takeoverID) != 16 {
		return errors.New("takeover_id must be a valid UUID")
	}
	var takeoverRow takeoverdomain.TakeoverAc
	if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverID).Take(&takeoverRow).Error; err != nil {
		return err
	}
	for i := range fileAttachmentIDs {
		fileAttachmentID := fileAttachmentIDs[i]
		if len(fileAttachmentID) != 16 {
			return errors.New("file_attachment_id must be a valid UUID")
		}
		var attachment filemanager.Attachment
		if err := tx.Where("id = ?", fileAttachmentID).Take(&attachment).Error; err != nil {
			return err
		}
		var existing takeoverdomain.TakeoverFile
		err := tx.Where("takeover_id = ? AND file_attachment_id = ?", takeoverID, fileAttachmentID).Take(&existing).Error
		if err == nil {
			continue
		}
		if !errors.Is(err, gorm.ErrRecordNotFound) {
			return err
		}
		row := &takeoverdomain.TakeoverFile{
			TakeoverID:       append([]byte(nil), takeoverID...),
			FileAttachmentID: append([]byte(nil), fileAttachmentID...),
		}
		if err := tx.Create(row).Error; err != nil {
			return err
		}
	}
	return nil
}

func (s *TakeoverService) withRetryTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
	const maxAttempts = 2
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		lastErr = s.db.WithContext(ctx).Transaction(fn)
		if lastErr == nil {
			return nil
		}
		if !isRetryableDBErr(lastErr) || attempt == maxAttempts {
			return lastErr
		}
	}
	return lastErr
}

func isRetryableDBErr(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "bad connection") ||
		strings.Contains(msg, "invalid connection") ||
		strings.Contains(msg, "connection refused") ||
		strings.Contains(msg, "broken pipe") ||
		strings.Contains(msg, "driver: bad connection")
}

func loadTakeoverExistingTx(tx *gorm.DB, flightID []byte) (*takeoverExisting, error) {
	var fl flight.Flight
	if err := tx.Where("id = ? AND deleted_at IS NULL", flightID).Take(&fl).Error; err != nil {
		return nil, err
	}
	var takeoverRow takeoverdomain.TakeoverAc
	if err := tx.Where("id = ? AND deleted_at IS NULL", fl.TakeoverAcID).Take(&takeoverRow).Error; err != nil {
		return nil, err
	}
	crews, err := loadTakeoverRosterCrewsTx(tx, takeoverRow.ID)
	if err != nil {
		return nil, err
	}
	others, err := loadTakeoverOtherPeopleTx(tx, takeoverRow.ID)
	if err != nil {
		return nil, err
	}
	var reserve *reserveac.ReserveAc
	if len(takeoverRow.ReserveAcID) == 16 {
		var reserveRow reserveac.ReserveAc
		if err := tx.Where("id = ? AND deleted_at IS NULL", takeoverRow.ReserveAcID).Take(&reserveRow).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		} else if err == nil {
			reserve = &reserveRow
		}
	}
	var beforeRow *beforeflightinspection.BeforeFlightInspection
	var prepRow *flightprepcheck.FlightPrepCheck
	if reserve != nil && len(reserve.InspectionID) == 16 {
		var before beforeflightinspection.BeforeFlightInspection
		if err := tx.Where("flight_inspection_id = ?", reserve.InspectionID).Take(&before).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		} else if err == nil {
			beforeRow = &before
		}
		var prep flightprepcheck.FlightPrepCheck
		if err := tx.Where("flight_inspection_id = ?", reserve.InspectionID).Take(&prep).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, err
		} else if err == nil {
			prepRow = &prep
		}
	}
	return &takeoverExisting{Flight: &fl, Takeover: &takeoverRow, Reserve: reserve, Crews: crews, Others: others, Before: beforeRow, Prep: prepRow}, nil
}

func dutyDateTimeString(dutyDate time.Time, clock string) string {
	trimmed := strings.TrimSpace(clock)
	if len(trimmed) == 5 {
		trimmed += ":00"
	}
	return dutyDate.UTC().Format("2006-01-02") + " " + trimmed
}

func ensureBaseExistsTx(tx *gorm.DB, id []byte) error {
	var count int64
	if err := tx.Table("bases").Where("id = ? AND deleted_at IS NULL", id).Count(&count).Error; err != nil {
		return err
	}
	if count == 0 {
		return errors.New("base_id does not exist")
	}
	return nil
}

func ensureHelicopterExistsTx(tx *gorm.DB, id []byte) error {
	var count int64
	if err := tx.Table("helicopters").Where("id = ?", id).Count(&count).Error; err != nil {
		return err
	}
	if count == 0 {
		return errors.New("helicopter_id does not exist")
	}
	return nil
}

func ResolveBaseShiftRangeTx(tx *gorm.DB, baseID []byte, dutyDate time.Time) (string, string, error) {
	if len(baseID) != 16 {
		return "", "", errors.New("base_id does not exist")
	}
	var baseRow basedomain.Base
	err := tx.Preload("OperationalShiftTimes", func(db *gorm.DB) *gorm.DB {
		return db.Where("deleted_at IS NULL").Order("date_start ASC, date_end ASC")
	}).
		Where("id = ? AND deleted_at IS NULL", baseID).
		First(&baseRow).Error
	if errors.Is(err, gorm.ErrRecordNotFound) {
		return "", "", errors.New("base_id does not exist")
	}
	if err != nil {
		return "", "", err
	}
	return util.ResolveShiftWindow(&baseRow, dutyDate)
}

func isHelicopterBookedTx(tx *gorm.DB, helicopterID []byte, excludeTakeoverID []byte) (bool, error) {
	if len(helicopterID) != 16 {
		return false, nil
	}
	q := tx.Table("reserve_acs ra").
		Joins("JOIN takeover_acs ta ON ta.reserve_ac_id = ra.id AND ta.deleted_at IS NULL").
		Joins("JOIN flights f ON f.takeover_ac_id = ta.id").
		Joins("LEFT JOIN after_flight_inspections afi ON afi.flight_inspection_id = ra.inspection_id").
		Where("ra.helicopter_id = ? AND ra.deleted_at IS NULL AND f.deleted_at IS NULL AND afi.id IS NULL", helicopterID)
	if len(excludeTakeoverID) == 16 {
		q = q.Where("ta.id <> ?", excludeTakeoverID)
	}
	var total int64
	if err := q.Count(&total).Error; err != nil {
		return false, err
	}
	return total > 0, nil
}

func ensureUserExistsTx(tx *gorm.DB, id []byte) error {
	var count int64
	if err := tx.Table("users").Where("id = ?", id).Count(&count).Error; err != nil {
		return err
	}
	if count == 0 {
		return errors.New("one or more crew contact ids do not exist")
	}
	return nil
}

func (s *TakeoverService) upsertTakeoverChecklistItemsTx(tx *gorm.DB, inspectionID, helicopterID []byte, section string, items []requestdto.ReserveAcFileChecklistItem, actor []byte) error {
	if s == nil || s.checklistFiles == nil {
		return errors.New("checklist template repository is not configured")
	}
	templateFiles, err := s.checklistFiles.ListChecklistTemplateFiles(tx, helicopterID, section)
	if err != nil {
		return err
	}
	if len(templateFiles) == 0 {
		if len(items) == 0 {
			return nil
		}
		return fmt.Errorf("inspection checklist template is empty for section %s", section)
	}
	if len(items) == 0 {
		return fmt.Errorf("%s is required", takeoverChecklistSectionLabel(section))
	}

	payloadByFileID := make(map[string]bool, len(items))
	for i := range items {
		if len(items[i].HelicopterFileID) == 0 {
			return errors.New("helicopter_file_id is required")
		}
		hfID := strings.TrimSpace(items[i].HelicopterFileID)
		parsedHFID, err := uuidv7.ParseString(hfID)
		if err != nil {
			return errors.New("invalid helicopter_file_id")
		}
		fileKey := string(parsedHFID)
		if _, exists := payloadByFileID[fileKey]; exists {
			return fmt.Errorf("duplicate helicopter_file_id in %s", takeoverChecklistSectionLabel(section))
		}
		payloadByFileID[fileKey] = items[i].IsDone
	}

	templateSet := make(map[string]struct{}, len(templateFiles))
	for i := range templateFiles {
		templateSet[string(templateFiles[i].ID)] = struct{}{}
	}

	for fileKey := range payloadByFileID {
		if _, ok := templateSet[fileKey]; ok {
			continue
		}
		return fmt.Errorf("helicopter_file_id does not belong to selected helicopter/section")
	}

	for i := range templateFiles {
		fileKey := string(templateFiles[i].ID)
		isDone, ok := payloadByFileID[fileKey]
		if !ok {
			// Only the files the user actually submitted are recorded. A catalog
			// file that was not edited need not be included in the checklist
			// (even if it is mandatory) — validateTakeoverEditedTemplateFilesTx
			// separately guarantees every edited file IS included.
			continue
		}
		if templateFiles[i].IsMandatory && !isDone {
			return fmt.Errorf("%s must be fully checked", takeoverChecklistSectionLabel(section))
		}
		row := &flightinspectionfilechecklist.FlightInspectionFileChecklist{
			HelicopterFileID:   templateFiles[i].ID,
			FlightInspectionID: inspectionID,
			IsDone:             isDone,
			CreatedBy:          actor,
			UpdatedBy:          actor,
		}
		if err := tx.Clauses(clause.OnConflict{
			Columns:   []clause.Column{{Name: "flight_inspection_id"}, {Name: "helicopter_file_id"}},
			DoUpdates: clause.AssignmentColumns([]string{"is_done", "updated_by"}),
		}).Create(row).Error; err != nil {
			return err
		}
	}
	return nil
}

// validateTakeoverEditedTemplateFilesTx enforces that every file that was
// created/edited from an FPWB template for this takeover is included in the
// submitted flight-preparation file checklist. A file that was not edited does
// not need to be in the checklist.
//
// New data stores template_uuid == HelicopterFile.ID. Legacy data may instead
// store the underlying source file ID. We accept both by resolving active FPWB
// checklist templates to a canonical helicopter_file_id before validating.
// Orphaned historical references that no longer map to an active checklist
// template are ignored so stale data cannot block takeover updates forever.
func (s *TakeoverService) validateTakeoverEditedTemplateFilesTx(tx *gorm.DB, takeoverID, helicopterID []byte, items []requestdto.ReserveAcFileChecklistItem) error {
	if len(takeoverID) != 16 {
		return nil
	}
	if s == nil || s.checklistFiles == nil {
		return errors.New("checklist template repository is not configured")
	}
	editedTemplateUUIDs, err := s.checklistFiles.ListActiveEditedTemplateUUIDs(tx, takeoverID)
	if err != nil {
		return err
	}
	if len(editedTemplateUUIDs) == 0 {
		return nil
	}

	templateFiles, err := s.checklistFiles.ListChecklistTemplateFiles(tx, helicopterID, helicopterfile.SectionFlightPreparationAndWB)
	if err != nil {
		return err
	}
	templateReferenceToChecklistID := make(map[string]string, len(templateFiles)*3)
	for i := range templateFiles {
		if len(templateFiles[i].ID) == 16 {
			canonicalID := string(templateFiles[i].ID)
			templateReferenceToChecklistID[canonicalID] = canonicalID
			if len(templateFiles[i].SourceFileID) == 16 {
				templateReferenceToChecklistID[string(templateFiles[i].SourceFileID)] = canonicalID
			}
			if templateFiles[i].SourceFile != nil && len(templateFiles[i].SourceFile.ID) == 16 {
				templateReferenceToChecklistID[string(templateFiles[i].SourceFile.ID)] = canonicalID
			}
			if templateFiles[i].FileAttachment != nil && templateFiles[i].FileAttachment.File != nil && len(templateFiles[i].FileAttachment.File.ID) == 16 {
				templateReferenceToChecklistID[string(templateFiles[i].FileAttachment.File.ID)] = canonicalID
			}
		}
	}

	checklistSet := make(map[string]struct{}, len(items))
	for i := range items {
		hfID := strings.TrimSpace(items[i].HelicopterFileID)
		if hfID == "" {
			continue
		}
		parsedHFID, err := uuidv7.ParseString(hfID)
		if err != nil {
			return errors.New("invalid helicopter_file_id")
		}
		checklistSet[string(parsedHFID)] = struct{}{}
	}

	for i := range editedTemplateUUIDs {
		if len(editedTemplateUUIDs[i]) != 16 {
			continue
		}
		requiredChecklistID, ok := templateReferenceToChecklistID[string(editedTemplateUUIDs[i])]
		if !ok {
			continue
		}
		if _, ok := checklistSet[requiredChecklistID]; !ok {
			return fmt.Errorf("edited template file must be included in the flight preparation file checklist")
		}
	}
	return nil
}

func takeoverChecklistSectionLabel(section string) string {
	switch section {
	case helicopterfile.SectionBeforeFirstFlightInspection:
		return "inspection.before.file_checklist"
	case helicopterfile.SectionFlightPreparationAndWB:
		return "inspection.prepare.file_checklist"
	default:
		return fmt.Sprintf("inspection.%s.file_checklist", section)
	}
}

// resolveOtherPeopleMasterInputTx fills each id-referenced other-person's name/phone/email
// from the master (a snapshot), so an id-only reuse still passes the "name required"
// validation and later upserts to the same master.
func resolveOtherPeopleMasterInputTx(tx *gorm.DB, detail *requestdto.DutyRosterDetailsInput) error {
	if detail == nil {
		return nil
	}
	for i := range detail.OtherPerson {
		idStr := strings.TrimSpace(detail.OtherPerson[i].OtherPersonID)
		if idStr == "" {
			continue
		}
		id, err := uuidv7.ParseString(idStr)
		if err != nil {
			return errors.New("other_person_id is invalid UUID")
		}
		var master otherpersondomain.OtherPerson
		if err := tx.Where("id = ? AND deleted_at IS NULL", id).First(&master).Error; err != nil {
			if errors.Is(err, gorm.ErrRecordNotFound) {
				return errors.New("other_person_id not found")
			}
			return err
		}
		detail.OtherPerson[i].Name = master.Name
		detail.OtherPerson[i].MobilePhone = master.MobilePhone
		detail.OtherPerson[i].Email = master.Email
	}
	return nil
}

// upsertOtherPersonTx returns the canonical master person for (name, mobile_phone, email),
// creating it when absent. On return p.ID is the canonical id.
func upsertOtherPersonTx(tx *gorm.DB, p *otherpersondomain.OtherPerson) error {
	name := strings.TrimSpace(p.Name)
	phone := strings.TrimSpace(p.MobilePhone)
	email := strings.TrimSpace(p.Email)
	if name == "" {
		return nil
	}
	var existing otherpersondomain.OtherPerson
	err := tx.Where("name = ? AND mobile_phone = ? AND email = ? AND deleted_at IS NULL", name, phone, email).First(&existing).Error
	if err == nil {
		p.ID = existing.ID
		return nil
	}
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return err
	}
	p.Name, p.MobilePhone, p.Email = name, phone, email
	return tx.Create(p).Error
}

func buildTakeoverCrewRows(details *requestdto.DutyRosterDetailsInput, actor []byte) ([]takeoverdomain.TakeoverRosterCrew, []takeoverdomain.TakeoverOtherPerson, *DutyRosterValidationError) {
	if details == nil {
		return []takeoverdomain.TakeoverRosterCrew{}, []takeoverdomain.TakeoverOtherPerson{}, nil
	}
	rows, err := BuildDutyRosterCrewRows(*details, actor)
	if err != nil {
		return nil, nil, err
	}
	crews := make([]takeoverdomain.TakeoverRosterCrew, 0, len(rows))
	others := make([]takeoverdomain.TakeoverOtherPerson, 0)
	for i := range rows {
		if strings.EqualFold(strings.TrimSpace(rows[i].RoleCode), "other_person") {
			others = append(others, takeoverdomain.TakeoverOtherPerson{
				Name:        rows[i].NameLabel,
				MobilePhone: rows[i].MobilePhone,
				Email:       rows[i].Email,
				CreatedBy:   actor,
				UpdatedBy:   actor,
			})
			continue
		}
		crews = append(crews, takeoverdomain.TakeoverRosterCrew{
			RoleCode:         rows[i].RoleCode,
			CrewType:         rows[i].CrewType,
			UserID:           append([]byte(nil), rows[i].UserID...),
			NameLabel:        rows[i].NameLabel,
			MobilePhone:      rows[i].MobilePhone,
			Email:            rows[i].Email,
			DateStart:        rows[i].DateStart,
			DateEnd:          rows[i].DateEnd,
			ShiftStart:       nullableTimeStringPtr(rows[i].ShiftStart),
			ShiftEnd:         nullableTimeStringPtr(rows[i].ShiftEnd),
			FlightInstructor: rows[i].FlightInstructor,
			LineChecker:      rows[i].LineChecker,
			Supervisor:       rows[i].Supervisor,
			Examiner:         rows[i].Examiner,
			CoPilot:          rows[i].CoPilot,
			CreatedBy:        actor,
			UpdatedBy:        actor,
		})
	}
	return crews, others, nil
}

func takeoverRosterCrewIdentityKey(row takeoverdomain.TakeoverRosterCrew) string {
	return fmt.Sprintf(
		"%s|%s|%s|%s|%s|%s|%s|%s|%s",
		strings.ToLower(strings.TrimSpace(row.RoleCode)),
		bytesKey(row.UserID),
		strings.ToLower(strings.TrimSpace(row.CrewType)),
		strings.ToLower(strings.TrimSpace(row.NameLabel)),
		strings.ToLower(strings.TrimSpace(row.MobilePhone)),
		strings.ToLower(strings.TrimSpace(row.Email)),
		nullableDateKey(row.DateStart),
		nullableDateKey(row.DateEnd),
		nullableTimeStringKey(row.ShiftStart)+"|"+nullableTimeStringKey(row.ShiftEnd),
	)
}

func takeoverRosterCrewPayloadEqual(a, b takeoverdomain.TakeoverRosterCrew) bool {
	return bytes.Equal(a.UserID, b.UserID) &&
		strings.EqualFold(strings.TrimSpace(a.RoleCode), strings.TrimSpace(b.RoleCode)) &&
		strings.EqualFold(strings.TrimSpace(a.CrewType), strings.TrimSpace(b.CrewType)) &&
		strings.TrimSpace(a.NameLabel) == strings.TrimSpace(b.NameLabel) &&
		strings.TrimSpace(a.MobilePhone) == strings.TrimSpace(b.MobilePhone) &&
		strings.TrimSpace(a.Email) == strings.TrimSpace(b.Email) &&
		nullableDateEqual(a.DateStart, b.DateStart) &&
		nullableDateEqual(a.DateEnd, b.DateEnd) &&
		nullableTimeStringEqual(a.ShiftStart, b.ShiftStart) &&
		nullableTimeStringEqual(a.ShiftEnd, b.ShiftEnd) &&
		a.FlightInstructor == b.FlightInstructor &&
		a.LineChecker == b.LineChecker &&
		a.Supervisor == b.Supervisor &&
		a.Examiner == b.Examiner &&
		a.CoPilot == b.CoPilot
}

func nullableDateEqual(a, b *time.Time) bool {
	switch {
	case a == nil && b == nil:
		return true
	case a == nil || b == nil:
		return false
	default:
		return dateOnlyUTC(*a).Equal(dateOnlyUTC(*b))
	}
}

func nullableDateKey(v *time.Time) string {
	if v == nil {
		return "nil"
	}
	return dateOnlyUTC(*v).Format("2006-01-02")
}

func bytesKey(v []byte) string {
	if len(v) == 0 {
		return "nil"
	}
	return fmt.Sprintf("%x", v)
}

func nullableTimeStringPtr(v string) *string {
	trimmed := strings.TrimSpace(v)
	if trimmed == "" {
		return nil
	}
	return &trimmed
}

func nullableTimeStringKey(v *string) string {
	if v == nil {
		return "nil"
	}
	return strings.TrimSpace(*v)
}

func nullableTimeStringEqual(a, b *string) bool {
	switch {
	case a == nil && b == nil:
		return true
	case a == nil || b == nil:
		return false
	default:
		return strings.TrimSpace(*a) == strings.TrimSpace(*b)
	}
}

func loadTakeoverRosterCrewsTx(tx *gorm.DB, takeoverID []byte) ([]takeoverdomain.TakeoverRosterCrew, error) {
	rows := make([]takeoverdomain.TakeoverRosterCrew, 0)
	if err := tx.Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return rows, nil
}

func loadTakeoverOtherPeopleTx(tx *gorm.DB, takeoverID []byte) ([]takeoverdomain.TakeoverOtherPerson, error) {
	rows := make([]takeoverdomain.TakeoverOtherPerson, 0)
	if err := tx.Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).Find(&rows).Error; err != nil {
		return nil, err
	}
	return rows, nil
}

func (s *TakeoverService) upsertTakeoverRosterRowsTx(
	tx *gorm.DB,
	takeoverID []byte,
	in TakeoverCreateInput,
) error {
	// Reuse: fill id-referenced other-people from the master (snapshot) BEFORE rows are
	// built, so an id-only reuse still passes the "name required" validation.
	if err := resolveOtherPeopleMasterInputTx(tx, in.RosterDetail); err != nil {
		return err
	}
	crews, others, crewErr := buildTakeoverCrewRows(in.RosterDetail, in.ActorUserID)
	if crewErr != nil {
		if strings.TrimSpace(crewErr.Detail) != "" {
			return errors.New(crewErr.Detail)
		}
		return errors.New("invalid roster_detail")
	}
	if len(crews) == 0 && len(others) == 0 {
		return nil
	}
	// Register/link each other-person to the reusable master (other_people). The takeover
	// keeps its own snapshot (name/phone/email); other_person_id points at the master.
	for i := range others {
		master := &otherpersondomain.OtherPerson{
			Name:        others[i].Name,
			MobilePhone: others[i].MobilePhone,
			Email:       others[i].Email,
			CreatedBy:   in.ActorUserID,
			UpdatedBy:   in.ActorUserID,
		}
		if err := upsertOtherPersonTx(tx, master); err != nil {
			return err
		}
		others[i].OtherPersonID = append([]byte(nil), master.ID...)
	}
	existingCrews, err := loadTakeoverRosterCrewsTx(tx, takeoverID)
	if err != nil {
		return err
	}
	existingOthers, err := loadTakeoverOtherPeopleTx(tx, takeoverID)
	if err != nil {
		return err
	}
	existingCrewMap := make(map[string]takeoverdomain.TakeoverRosterCrew, len(existingCrews))
	for i := range existingCrews {
		existingCrewMap[takeoverRosterCrewIdentityKey(existingCrews[i])] = existingCrews[i]
	}
	existingOtherMap := make(map[string]takeoverdomain.TakeoverOtherPerson, len(existingOthers))
	for i := range existingOthers {
		key := strings.ToLower(strings.TrimSpace(existingOthers[i].Name)) + "|" +
			strings.ToLower(strings.TrimSpace(existingOthers[i].MobilePhone)) + "|" +
			strings.ToLower(strings.TrimSpace(existingOthers[i].Email))
		existingOtherMap[key] = existingOthers[i]
	}
	touchedCrewKeys := make(map[string]struct{}, len(crews))
	touchedOtherKeys := make(map[string]struct{}, len(others))
	for i := range crews {
		crews[i].TakeoverID = append([]byte(nil), takeoverID...)
		key := takeoverRosterCrewIdentityKey(crews[i])
		touchedCrewKeys[key] = struct{}{}
		if existingRow, ok := existingCrewMap[key]; ok {
			crews[i].ID = append([]byte(nil), existingRow.ID...)
			if takeoverRosterCrewPayloadEqual(existingRow, crews[i]) {
				continue
			}
			if err := tx.Model(&takeoverdomain.TakeoverRosterCrew{}).
				Where("id = ? AND deleted_at IS NULL", crews[i].ID).
				Updates(map[string]any{
					"takeover_id":       crews[i].TakeoverID,
					"confirm_at":        crews[i].ConfirmAt,
					"role_code":         crews[i].RoleCode,
					"crew_type":         crews[i].CrewType,
					"user_id":           crews[i].UserID,
					"name_label":        crews[i].NameLabel,
					"mobile_phone":      crews[i].MobilePhone,
					"email":             crews[i].Email,
					"date_start":        crews[i].DateStart,
					"date_end":          crews[i].DateEnd,
					"shift_start":       crews[i].ShiftStart,
					"shift_end":         crews[i].ShiftEnd,
					"flight_instructor": crews[i].FlightInstructor,
					"line_checker":      crews[i].LineChecker,
					"supervisor":        crews[i].Supervisor,
					"examiner":          crews[i].Examiner,
					"co_pilot":          crews[i].CoPilot,
					"updated_by":        in.ActorUserID,
				}).Error; err != nil {
				return err
			}
			continue
		}
		if err := tx.Create(&crews[i]).Error; err != nil {
			return err
		}
	}
	for _, existingRow := range existingCrews {
		key := takeoverRosterCrewIdentityKey(existingRow)
		if _, ok := touchedCrewKeys[key]; ok {
			continue
		}
		if err := tx.Model(&takeoverdomain.TakeoverRosterCrew{}).
			Where("id = ? AND deleted_at IS NULL", existingRow.ID).
			Updates(map[string]any{"deleted_at": time.Now().UTC(), "deleted_by": in.ActorUserID, "updated_by": in.ActorUserID}).Error; err != nil {
			return err
		}
	}
	for i := range others {
		others[i].TakeoverID = append([]byte(nil), takeoverID...)
		key := strings.ToLower(strings.TrimSpace(others[i].Name)) + "|" +
			strings.ToLower(strings.TrimSpace(others[i].MobilePhone)) + "|" +
			strings.ToLower(strings.TrimSpace(others[i].Email))
		touchedOtherKeys[key] = struct{}{}
		if existingRow, ok := existingOtherMap[key]; ok {
			others[i].ID = append([]byte(nil), existingRow.ID...)
			if strings.TrimSpace(existingRow.Name) == strings.TrimSpace(others[i].Name) &&
				strings.TrimSpace(existingRow.MobilePhone) == strings.TrimSpace(others[i].MobilePhone) &&
				strings.TrimSpace(existingRow.Email) == strings.TrimSpace(others[i].Email) {
				continue
			}
			if err := tx.Model(&takeoverdomain.TakeoverOtherPerson{}).
				Where("id = ? AND deleted_at IS NULL", others[i].ID).
				Updates(map[string]any{
					"takeover_id":     others[i].TakeoverID,
					"other_person_id": others[i].OtherPersonID,
					"name":            others[i].Name,
					"mobile_phone":    others[i].MobilePhone,
					"email":           others[i].Email,
					"updated_by":      in.ActorUserID,
				}).Error; err != nil {
				return err
			}
			continue
		}
		if err := tx.Create(&others[i]).Error; err != nil {
			return err
		}
	}
	for _, existingRow := range existingOthers {
		key := strings.ToLower(strings.TrimSpace(existingRow.Name)) + "|" +
			strings.ToLower(strings.TrimSpace(existingRow.MobilePhone)) + "|" +
			strings.ToLower(strings.TrimSpace(existingRow.Email))
		if _, ok := touchedOtherKeys[key]; ok {
			continue
		}
		if err := tx.Model(&takeoverdomain.TakeoverOtherPerson{}).
			Where("id = ? AND deleted_at IS NULL", existingRow.ID).
			Updates(map[string]any{"deleted_at": time.Now().UTC(), "deleted_by": in.ActorUserID, "updated_by": in.ActorUserID}).Error; err != nil {
			return err
		}
	}
	return nil
}

func (s *TakeoverService) upsertTakeoverInspectionTx(
	tx *gorm.DB,
	takeoverID []byte,
	inspectionID []byte,
	in *requestdto.ReserveAcInspectionCreateAttributes,
	helicopterID []byte,
	actor []byte,
) ([]byte, error) {
	if in == nil {
		return inspectionID, nil
	}
	if len(helicopterID) != 16 {
		return nil, errors.New("helicopter_id is required for inspection data")
	}
	// Every file edited/created from an FPWB template for this takeover must be
	// represented in the submitted flight-preparation file checklist. Files that
	// were not edited need not be in the checklist.
	if err := s.validateTakeoverEditedTemplateFilesTx(tx, takeoverID, helicopterID, in.Prepare.FileChecklist); err != nil {
		return nil, err
	}
	inspectionDate, err := time.Parse("2006-01-02", strings.TrimSpace(in.InspectionDate))
	if err != nil {
		return nil, errors.New("inspection.inspection_date must be YYYY-MM-DD")
	}
	if len(inspectionID) == 0 {
		inspection := &flightinspection.FlightInspection{
			InspectionDate: dateOnlyUTC(inspectionDate),
			Status:         flightinspection.StatusDraft,
			CreatedBy:      actor,
			UpdatedBy:      actor,
		}
		if err := tx.Create(inspection).Error; err != nil {
			return nil, err
		}
		inspectionID = append([]byte(nil), inspection.ID...)
	} else {
		if err := tx.Model(&flightinspection.FlightInspection{}).
			Where("id = ?", inspectionID).
			Updates(map[string]any{
				"inspection_date": inspectionDate,
				"updated_by":      actor,
			}).Error; err != nil {
			return nil, err
		}
	}

	beforeRow := &beforeflightinspection.BeforeFlightInspection{
		FlightInspectionID:        append([]byte(nil), inspectionID...),
		OilEngineNR1Checked:       in.Before.OilEngineNR1Checked,
		OilEngineNR2Checked:       in.Before.OilEngineNR2Checked,
		HydraulicLHChecked:        in.Before.HydraulicLHChecked,
		HydraulicRHChecked:        in.Before.HydraulicRHChecked,
		OilTransmissionMGBChecked: in.Before.OilTransmissionMGBChecked,
		OilTransmissionIGBChecked: in.Before.OilTransmissionIGBChecked,
		OilTransmissionTGBChecked: in.Before.OilTransmissionTGBChecked,
		FuelAmount:                in.Before.FuelAmount,
		FuelAddedAmount:           in.Before.FuelAddedAmount,
		Note:                      in.Before.Note,
		CreatedBy:                 actor,
		UpdatedBy:                 actor,
	}
	if in.Before.FuelUnit != nil {
		unit, ok := beforeflightinspection.NormalizeFuelUnit(*in.Before.FuelUnit)
		if !ok {
			return nil, errors.New("inspection.before.fuel_unit must be one of LT, KG, POUND")
		}
		beforeRow.FuelUnit = unit
	}
	if err := tx.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "flight_inspection_id"}},
		DoUpdates: clause.AssignmentColumns([]string{
			"oil_engine_nr1_checked",
			"oil_engine_nr2_checked",
			"hydraulic_lh_checked",
			"hydraulic_rh_checked",
			"oil_transmission_mgb_checked",
			"oil_transmission_igb_checked",
			"oil_transmission_tgb_checked",
			"fuel_amount",
			"fuel_unit",
			"fuel_added_amount",
			"note",
			"updated_by",
		}),
	}).Create(beforeRow).Error; err != nil {
		return nil, err
	}

	prepRow := &flightprepcheck.FlightPrepCheck{
		FlightInspectionID: append([]byte(nil), inspectionID...),
		CreatedBy:          actor,
		UpdatedBy:          actor,
	}
	if in.Prepare.NotamBriefing != nil {
		prepRow.NOTAMBriefing = *in.Prepare.NotamBriefing
	}
	if in.Prepare.WeatherBriefing != nil {
		prepRow.WeatherBriefing = *in.Prepare.WeatherBriefing
	}
	if in.Prepare.OperationalFlightPlan != nil {
		prepRow.OperationalFlightPlan = *in.Prepare.OperationalFlightPlan
	}
	if err := tx.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "flight_inspection_id"}},
		DoUpdates: clause.AssignmentColumns([]string{
			"notam_briefing",
			"weather_briefing",
			"operational_flight_plan",
			"updated_by",
		}),
	}).Create(prepRow).Error; err != nil {
		return nil, err
	}

	if len(in.Before.FileChecklist) > 0 {
		if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionBeforeFirstFlightInspection, in.Before.FileChecklist, actor); err != nil {
			return nil, err
		}
	}
	if len(in.Prepare.FileChecklist) > 0 {
		if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionFlightPreparationAndWB, in.Prepare.FileChecklist, actor); err != nil {
			return nil, err
		}
	}
	return inspectionID, nil
}

func (s *TakeoverService) upsertTakeoverReserveTx(
	tx *gorm.DB,
	takeoverID []byte,
	baseID []byte,
	existing *takeoverExisting,
	in TakeoverCreateInput,
) error {
	if existing == nil {
		return errors.New("takeover state is not loaded")
	}
	if existing.Reserve == nil {
		if !in.HasHelicopterID && !in.HasInspection {
			return nil
		}
		if !in.HasHelicopterID || !in.HasInspection || in.Inspection == nil {
			return errors.New("helicopter_id and inspection are required to create reserve data")
		}
		if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.ensureHelicopterExistsTx", err)
			return err
		}
		inspectionID, err := s.upsertTakeoverInspectionTx(tx, takeoverID, nil, in.Inspection, in.HelicopterID, in.ActorUserID)
		if err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.upsertTakeoverInspectionTx(create)", err)
			return err
		}
		reserveRow := &reserveac.ReserveAc{
			Status:       reserveacutil.StatusPending,
			BaseID:       append([]byte(nil), baseID...),
			Notes:        in.Notes,
			AircraftID:   append([]byte(nil), in.HelicopterID...),
			InspectionID: inspectionID,
			CreatedBy:    in.ActorUserID,
			UpdatedBy:    in.ActorUserID,
		}
		if err := tx.Create(reserveRow).Error; err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.createReserve", err)
			return err
		}
		if err := tx.Model(&takeoverdomain.TakeoverAc{}).
			Where("id = ? AND deleted_at IS NULL", takeoverID).
			Updates(map[string]any{"reserve_ac_id": reserveRow.ID, "updated_by": in.ActorUserID}).Error; err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.attachReserveToTakeover", err)
			return err
		}
		existing.Reserve = reserveRow
		return nil
	}

	changed := false
	if len(baseID) == 16 && !bytes.Equal(existing.Reserve.BaseID, baseID) {
		existing.Reserve.BaseID = append([]byte(nil), baseID...)
		changed = true
	}
	if in.Notes != nil && (existing.Reserve.Notes == nil || *existing.Reserve.Notes != *in.Notes) {
		existing.Reserve.Notes = in.Notes
		changed = true
	}
	if in.HasHelicopterID {
		if len(in.HelicopterID) != 16 {
			return errors.New("helicopter_id must be a valid UUID")
		}
		if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.ensureHelicopterExistsTx(update)", err)
			return err
		}
		if !bytes.Equal(existing.Reserve.AircraftID, in.HelicopterID) {
			existing.Reserve.AircraftID = append([]byte(nil), in.HelicopterID...)
			changed = true
		}
	}
	if in.HasInspection {
		if in.Inspection == nil {
			return errors.New("inspection data is required")
		}
		helicopterID := existing.Reserve.AircraftID
		if in.HasHelicopterID {
			helicopterID = in.HelicopterID
		}
		if len(helicopterID) != 16 {
			return errors.New("helicopter_id is required for inspection data")
		}
		inspectionID, err := s.upsertTakeoverInspectionTx(tx, takeoverID, existing.Reserve.InspectionID, in.Inspection, helicopterID, in.ActorUserID)
		if err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.upsertTakeoverInspectionTx(update)", err)
			return err
		}
		if len(existing.Reserve.InspectionID) != 16 || !bytes.Equal(existing.Reserve.InspectionID, inspectionID) {
			existing.Reserve.InspectionID = append([]byte(nil), inspectionID...)
			changed = true
		}
	}
	if changed {
		existing.Reserve.UpdatedBy = in.ActorUserID
		if err := tx.Save(existing.Reserve).Error; err != nil {
			logTakeoverServiceStepError("upsertTakeoverReserveTx.saveReserve", err)
			return err
		}
	}
	return nil
}

/*

func (s *TakeoverService) upsertTakeoverReserveTx(
	tx *gorm.DB,
	takeoverID []byte,
	baseID []byte,
	existing *takeoverExisting,
	in TakeoverCreateInput,
) error {
	if existing == nil {
		return errors.New("takeover state is not available")
	}
	if existing.Reserve == nil {
		if !in.HasHelicopterID && !in.HasInspection {
			return nil
		}
		if !in.HasHelicopterID || len(in.HelicopterID) != 16 {
			return errors.New("helicopter_id is required to create reserve data")
		}
		if !in.HasInspection || in.Inspection == nil {
			return errors.New("inspection is required to create reserve data")
		}
		if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
			return err
		}
		inspectionID, err := upsertTakeoverInspectionTx(tx, nil, in.Inspection, in.ActorUserID, in.HelicopterID)
		if err != nil {
			return err
		}
		reserveRow := &reserveac.ReserveAc{
			Status:       reserveacutil.StatusPending,
			BaseID:       append([]byte(nil), baseID...),
			Notes:        in.Notes,
			AircraftID:   append([]byte(nil), in.HelicopterID...),
			InspectionID: append([]byte(nil), inspectionID...),
			CreatedBy:    in.ActorUserID,
			UpdatedBy:    in.ActorUserID,
		}
		if err := tx.Create(reserveRow).Error; err != nil {
			return err
		}
		if err := tx.Model(&takeoverdomain.TakeoverAc{}).
			Where("id = ? AND deleted_at IS NULL", takeoverID).
			Updates(map[string]any{"reserve_ac_id": reserveRow.ID, "updated_by": in.ActorUserID}).Error; err != nil {
			return err
		}
		existing.Reserve = reserveRow
		return nil
	}

	changed := false
	if in.HasBaseID && !bytes.Equal(existing.Reserve.BaseID, baseID) {
		existing.Reserve.BaseID = append([]byte(nil), baseID...)
		changed = true
	}
	if in.Notes != nil && (existing.Reserve.Notes == nil || strings.TrimSpace(*existing.Reserve.Notes) != strings.TrimSpace(*in.Notes)) {
		existing.Reserve.Notes = in.Notes
		changed = true
	}
	if in.HasHelicopterID {
		if len(in.HelicopterID) != 16 {
			return errors.New("helicopter_id must be a valid UUID")
		}
		if err := ensureHelicopterExistsTx(tx, in.HelicopterID); err != nil {
			return err
		}
		if !bytes.Equal(existing.Reserve.AircraftID, in.HelicopterID) {
			existing.Reserve.AircraftID = append([]byte(nil), in.HelicopterID...)
			changed = true
		}
	}
	if in.HasInspection {
		if in.Inspection == nil {
			return errors.New("inspection data is required when inspection is provided")
		}
		heliID := existing.Reserve.AircraftID
		if in.HasHelicopterID {
			heliID = in.HelicopterID
		}
		if len(heliID) != 16 {
			return errors.New("helicopter_id is required to update inspection data")
		}
		inspectionID, err := upsertTakeoverInspectionTx(tx, existing.Reserve.InspectionID, in.Inspection, in.ActorUserID, heliID)
		if err != nil {
			return err
		}
		if len(existing.Reserve.InspectionID) != 16 || !bytes.Equal(existing.Reserve.InspectionID, inspectionID) {
			existing.Reserve.InspectionID = append([]byte(nil), inspectionID...)
			changed = true
		}
	}
	if changed {
		existing.Reserve.UpdatedBy = in.ActorUserID
		if err := tx.Save(existing.Reserve).Error; err != nil {
			return err
		}
	}
	return nil
}

func upsertTakeoverInspectionTx(tx *gorm.DB, inspectionID []byte, in *requestdto.ReserveAcInspectionCreateAttributes, actor []byte, helicopterID []byte) ([]byte, error) {
	if in == nil {
		return inspectionID, nil
	}
	existingInspection := len(inspectionID) == 16
	if len(helicopterID) != 16 {
		return nil, errors.New("helicopter_id is required to update inspection data")
	}
	if err := validateTakeoverInspectionChecklists(in); err != nil {
		logTakeoverServiceStepError("upsertTakeoverInspectionTx.validateTakeoverInspectionChecklists", err)
		return nil, err
	}
	inspectionDate, err := time.Parse("2006-01-02", strings.TrimSpace(in.InspectionDate))
	if err != nil {
		logTakeoverServiceStepError("upsertTakeoverInspectionTx.parseInspectionDate", err)
		return nil, errors.New("inspection.inspection_date must be YYYY-MM-DD")
	}
	if len(inspectionID) != 16 {
		inspection := &flightinspection.FlightInspection{
			InspectionDate: dateOnlyUTC(inspectionDate),
			Status:         flightinspection.StatusDraft,
			CreatedBy:      actor,
			UpdatedBy:      actor,
		}
		if err := tx.Create(inspection).Error; err != nil {
			logTakeoverServiceStepError("upsertTakeoverInspectionTx.createFlightInspection", err)
			return nil, err
		}
		inspectionID = append([]byte(nil), inspection.ID...)
	} else if err := tx.Model(&flightinspection.FlightInspection{}).
		Where("id = ?", inspectionID).
		Updates(map[string]any{"inspection_date": dateOnlyUTC(inspectionDate), "updated_by": actor}).Error; err != nil {
		logTakeoverServiceStepError("upsertTakeoverInspectionTx.updateFlightInspection", err)
		return nil, err
	}

	beforeRow := &beforeflightinspection.BeforeFlightInspection{
		FlightInspectionID:        append([]byte(nil), inspectionID...),
		OilEngineNR1Checked:       in.Before.OilEngineNR1Checked,
		OilEngineNR2Checked:       in.Before.OilEngineNR2Checked,
		HydraulicLHChecked:        in.Before.HydraulicLHChecked,
		HydraulicRHChecked:        in.Before.HydraulicRHChecked,
		OilTransmissionMGBChecked: in.Before.OilTransmissionMGBChecked,
		OilTransmissionIGBChecked: in.Before.OilTransmissionIGBChecked,
		OilTransmissionTGBChecked: in.Before.OilTransmissionTGBChecked,
		FuelAmount:                in.Before.FuelAmount,
		FuelAddedAmount:           in.Before.FuelAddedAmount,
		Note:                      in.Before.Note,
		CreatedBy:                 actor,
		UpdatedBy:                 actor,
	}
	if in.Before.FuelUnit != nil {
		unit, ok := beforeflightinspection.NormalizeFuelUnit(*in.Before.FuelUnit)
		if !ok {
			return nil, errors.New("inspection.before.fuel_unit must be one of LT, KG, POUND")
		}
		beforeRow.FuelUnit = unit
	}
	if err := tx.Clauses(clause.OnConflict{
		Columns:   []clause.Column{{Name: "flight_inspection_id"}},
		DoUpdates: clause.AssignmentColumns([]string{"oil_engine_nr1_checked", "oil_engine_nr2_checked", "hydraulic_lh_checked", "hydraulic_rh_checked", "oil_transmission_mgb_checked", "oil_transmission_igb_checked", "oil_transmission_tgb_checked", "fuel_amount", "fuel_unit", "fuel_added_amount", "note", "updated_by"}),
	}).Create(beforeRow).Error; err != nil {
		logTakeoverServiceStepError("upsertTakeoverInspectionTx.upsertBeforeFlightInspection", err)
		return nil, err
	}

	prepRow := &flightprepcheck.FlightPrepCheck{
		FlightInspectionID: append([]byte(nil), inspectionID...),
		CreatedBy:          actor,
		UpdatedBy:          actor,
	}
	if in.Prepare.NotamBriefing != nil {
		prepRow.NOTAMBriefing = *in.Prepare.NotamBriefing
	}
	if in.Prepare.WeatherBriefing != nil {
		prepRow.WeatherBriefing = *in.Prepare.WeatherBriefing
	}
	if in.Prepare.OperationalFlightPlan != nil {
		prepRow.OperationalFlightPlan = *in.Prepare.OperationalFlightPlan
	}
	if err := tx.Clauses(clause.OnConflict{
		Columns:   []clause.Column{{Name: "flight_inspection_id"}},
		DoUpdates: clause.AssignmentColumns([]string{"notam_briefing", "weather_briefing", "operational_flight_plan", "updated_by"}),
	}).Create(prepRow).Error; err != nil {
		logTakeoverServiceStepError("upsertTakeoverInspectionTx.upsertFlightPrepCheck", err)
		return nil, err
	}

	if !existingInspection || len(in.Before.FileChecklist) > 0 {
		if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionBeforeFirstFlightInspection, in.Before.FileChecklist, actor); err != nil {
			logTakeoverServiceStepError("upsertTakeoverInspectionTx.beforeChecklist", err)
			return nil, err
		}
	}
	if !existingInspection || len(in.Prepare.FileChecklist) > 0 {
		if err := s.upsertTakeoverChecklistItemsTx(tx, inspectionID, helicopterID, helicopterfile.SectionFlightPreparationAndWB, in.Prepare.FileChecklist, actor); err != nil {
			logTakeoverServiceStepError("upsertTakeoverInspectionTx.prepareChecklist", err)
			return nil, err
		}
	}
	// On updates, an omitted fleet_status_file block means "keep the existing
	// checklist as-is". Only enforce the mandatory-file check when the caller
	// actually sends the section, or when we're creating a brand new inspection.
	if !existingInspection || len(in.FleetStatusFile.FileChecklist) > 0 {
		if err := validateMandatoryFleetStatusFilesTx(tx, helicopterID, in.FleetStatusFile.FileChecklist); err != nil {
			logTakeoverServiceStepError("upsertTakeoverInspectionTx.validateMandatoryFleetStatusFilesTx", err)
			return nil, err
		}
	}
	return inspectionID, nil
}
*/

func validateTakeoverInspectionChecklists(in *requestdto.ReserveAcInspectionCreateAttributes) error {
	if in == nil {
		return nil
	}
	if err := validateTakeoverChecklistSection(in.Before.FileChecklist, "inspection.before.file_checklist"); err != nil {
		return err
	}
	if err := validateTakeoverChecklistSection(in.Prepare.FileChecklist, "inspection.prepare.file_checklist"); err != nil {
		return err
	}
	return nil
}

func validateTakeoverChecklistSection(items []requestdto.ReserveAcFileChecklistItem, label string) error {
	if len(items) == 0 {
		return nil
	}
	for i := range items {
		if !items[i].IsDone {
			return fmt.Errorf("%s must be fully checked", label)
		}
	}
	return nil
}

func validateMandatoryFleetStatusFilesTx(tx *gorm.DB, helicopterID []byte, items []requestdto.ReserveAcFileChecklistItem) error {
	if len(helicopterID) != 16 {
		return nil
	}
	type fsfRow struct {
		ID []byte `gorm:"column:id"`
	}
	latest := tx.Table("fleet_statuses").
		Select("id").
		Where("helicopter_id = ? AND deleted_at IS NULL", helicopterID).
		Order("created_at DESC").
		Limit(1)
	mandatory := make([]fsfRow, 0)
	if err := tx.Table("fleet_status_files").
		Select("id").
		Where("is_mandatory = ? AND fleet_status_id = (?)", true, latest).
		Scan(&mandatory).Error; err != nil {
		return err
	}
	if len(mandatory) == 0 {
		return nil
	}
	done := make(map[string]bool, len(items))
	for i := range items {
		id, err := uuidv7.ParseString(strings.TrimSpace(items[i].HelicopterFileID))
		if err != nil {
			continue
		}
		done[string(id)] = items[i].IsDone
	}
	for _, m := range mandatory {
		if !done[string(m.ID)] {
			return fmt.Errorf("inspection.fleet_status_file: a mandatory file must be checked (is_done) before takeover")
		}
	}
	return nil
}

func loadTakeoverReserveTx(tx *gorm.DB, id []byte) (*reserveac.ReserveAc, error) {
	if len(id) != 16 {
		return nil, nil
	}
	var row reserveac.ReserveAc
	if err := tx.Where("id = ? AND deleted_at IS NULL", id).Take(&row).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}
		return nil, err
	}
	return &row, nil
}

func getLatestMissionCodeByPrefixTx(tx *gorm.DB, prefix string) (string, error) {
	var out struct {
		MissionCode string `gorm:"column:mission_code"`
	}
	err := tx.Table("flights").
		Select("mission_code").
		Where("mission_code LIKE ? AND deleted_at IS NULL", prefix+"%").
		Order("mission_code DESC").
		Limit(1).
		Scan(&out).Error
	if err != nil {
		return "", err
	}
	return out.MissionCode, nil
}
