package handlers

import (
	"context"
	"errors"
	"regexp"
	"strconv"
	"strings"
	"time"

	mysqldriver "github.com/go-sql-driver/mysql"
	"github.com/gofiber/fiber/v2"
	"gorm.io/gorm"

	dutyroster "wucher/internal/domain/duty_roster"
	"wucher/internal/domain/flight"
	"wucher/internal/service"
	"wucher/internal/shared/pkg/apperrorsx"
	"wucher/internal/shared/pkg/uuidv7"
	dto "wucher/internal/transport/http/dto"
	requestdto "wucher/internal/transport/http/dto/request"
	responsedto "wucher/internal/transport/http/dto/response"
	"wucher/internal/transport/http/jsonapi"
	"wucher/internal/transport/http/response"
	"wucher/internal/transport/http/validators"
)

type DutyRosterHandler struct {
	svc      dutyroster.Service
	flight   flight.FlightService
	validate *validators.Validator
	audit    *service.AuditLogService
}

var _ responsedto.DutyRosterResponse
var _ dto.ErrorResponse
var _ dto.GenericDeleteResponse

type rosterFlightMeta struct {
	FlightID   string
	Helicopter responsedto.DutyRosterHelicopterSummary
}

func toCoreDutyRosterDetailsInput(in requestdto.DutyRosterDetailsInput) requestdto.DutyRosterDetailsInput {
	out := requestdto.DutyRosterDetailsInput{
		Pilot:       make([]requestdto.DutyRosterPilotCrewInput, 0, len(in.Pilot)),
		Doctor:      make([]requestdto.DutyRosterCrewInput, 0, len(in.Doctor)),
		Rescuer:     make([]requestdto.DutyRosterCrewInput, 0, len(in.Rescuer)),
		Helper:      make([]requestdto.DutyRosterCrewInput, 0, len(in.Helper)),
		OtherPerson: make([]requestdto.DutyRosterOtherPersonInput, 0, len(in.OtherPerson)),
	}
	for i := range in.Pilot {
		out.Pilot = append(out.Pilot, requestdto.DutyRosterPilotCrewInput{
			ID:               in.Pilot[i].ID,
			ShiftDate:        toCoreShiftDatePtr(in.Pilot[i].ShiftDate),
			FlightInstructor: in.Pilot[i].FlightInstructor,
			LineChecker:      in.Pilot[i].LineChecker,
			Supervisor:       in.Pilot[i].Supervisor,
			Examiner:         in.Pilot[i].Examiner,
			CoPilot:          in.Pilot[i].CoPilot,
			CrewType:         in.Pilot[i].CrewType,
		})
	}
	for i := range in.Doctor {
		out.Doctor = append(out.Doctor, toCoreCrewInput(in.Doctor[i]))
	}
	for i := range in.Rescuer {
		out.Rescuer = append(out.Rescuer, toCoreCrewInput(in.Rescuer[i]))
	}
	for i := range in.Helper {
		out.Helper = append(out.Helper, toCoreCrewInput(in.Helper[i]))
	}
	for i := range in.OtherPerson {
		out.OtherPerson = append(out.OtherPerson, requestdto.DutyRosterOtherPersonInput{
			Name:        in.OtherPerson[i].Name,
			MobilePhone: in.OtherPerson[i].MobilePhone,
			Email:       in.OtherPerson[i].Email,
		})
	}
	return out
}

func toCoreDutyRosterDetailsInputPtr(in *requestdto.DutyRosterDetailsInput) *requestdto.DutyRosterDetailsInput {
	if in == nil {
		return nil
	}
	out := toCoreDutyRosterDetailsInput(*in)
	return &out
}

func toCoreCrewInput(in requestdto.DutyRosterCrewInput) requestdto.DutyRosterCrewInput {
	return requestdto.DutyRosterCrewInput{
		ID:        in.ID,
		ShiftDate: toCoreShiftDatePtr(in.ShiftDate),
	}
}

func toCoreShiftDatePtr(in *requestdto.DutyRosterShiftDate) *requestdto.DutyRosterShiftDate {
	if in == nil {
		return nil
	}
	return &requestdto.DutyRosterShiftDate{
		DateStart: in.DateStart,
		DateEnd:   in.DateEnd,
	}
}

func NewDutyRosterHandler(svc dutyroster.Service) *DutyRosterHandler {
	return &DutyRosterHandler{svc: svc, validate: validators.New()}
}

func (h *DutyRosterHandler) WithAudit(auditSvc *service.AuditLogService) *DutyRosterHandler {
	h.audit = auditSvc
	return h
}

func (h *DutyRosterHandler) WithFlightService(flightSvc flight.FlightService) *DutyRosterHandler {
	h.flight = flightSvc
	return h
}

// CreateDutyRoster godoc
// @Summary Create HEMS duty roster
// @Description Create roster by `hems_base_id` and `duty_date`.
// @Description HEMS endpoints use `hems_base_id` in request and `hems_bases` in response attributes.
// @Description Time input accepts `HH:MM` or `HH:MM:SS` and is normalized.
// @Description `roster_detail` buckets: `pilot`, `doctor`, `rescuer`, `helper`, `other_person`.
// @Description For pilot: `crew_type` can be `main` or `additional`.
// @Description Pilot flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied when `crew_type=additional`; for `main` they are ignored.
// @Description For doctor/rescuer/helper, `crew_type` is always treated as `main`.
// @Description For `other_person`, `id` must be empty and `name` is required.
// @Description If roster shift_time is omitted, system uses base default shift when available.
// @Description `data.type` should be `duty_roster_create` (or `duty_roster`). Legacy `hems_duty_roster_create`/`hems_duty_roster` is still accepted for backward compatibility.
// @Tags Flights - HEMS Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterCreateRequest true "Create request payload"
// @Success 201 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/create
func (h *DutyRosterHandler) Create(c *fiber.Ctx) error {
	var req requestdto.DutyRosterCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "base_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
		}})
	}
	dutyDate, err := service.ParseDutyRosterDateOnly(req.Data.Attributes.DutyDate)
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "duty_date must be YYYY-MM-DD",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"},
		}})
	}

	row := &dutyroster.DutyRoster{
		BaseID:    baseID,
		DutyDate:  dutyDate,
		CreatedBy: actorUserID(c),
		UpdatedBy: actorUserID(c),
	}
	defaultShiftStart, defaultShiftEnd, err := h.svc.ResolveBaseDefaultShiftRange(c.UserContext(), baseID, "hems")
	if err != nil {
		if wrote := h.writeBaseTypeValidationError(c, err, baseID, "hems", "/data/attributes/base_id"); wrote {
			return nil
		}
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	rowShiftStart, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start")
	if shiftErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: shiftErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
		}})
	}
	rowShiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end")
	if shiftErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: shiftErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
		}})
	}
	_ = rowShiftStart
	_ = rowShiftEnd
	detail := toCoreDutyRosterDetailsInput(req.Data.Attributes.Detail)
	crews, crewErr := service.BuildDutyRosterCrewRows(detail, actorUserID(c))
	if crewErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: crewErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer},
		}})
	}
	clearCrewShiftDateForCreate(crews)

	if err := h.svc.Create(c.UserContext(), row, crews); err != nil {
		status, errObj := buildServiceErrorObject("Create failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	return h.respondByID(c, fiber.StatusCreated, row.ID)
}

// GetDutyRoster godoc
// @Summary Get HEMS duty roster by UUID
// @Tags Flights - HEMS Duty Roster
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Success 200 {object} responsedto.DutyRosterResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/get/{uuid}
func (h *DutyRosterHandler) Get(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}

	header, err := h.svc.GetHeaderByID(c.UserContext(), id)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if header != nil {
		return h.respondByID(c, fiber.StatusOK, id)
	}

	// Backward-compatible fallback: allow :uuid as flight_uuid.
	if h.flight != nil {
		flightRow, flightErr := h.flight.GetByID(c.UserContext(), id)
		if flightErr != nil {
			status, errObj := buildServiceErrorObject("Get failed", flightErr, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		if flightRow != nil && len(flightRow.DutyRosterID) == 16 {
			return h.respondByID(c, fiber.StatusOK, flightRow.DutyRosterID)
		}
	}

	return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
		Status: "404",
		Title:  "Not found",
		Detail: "hems duty roster not found",
	}})
}

func (h *DutyRosterHandler) GetByAuthUser(c *fiber.Ctx) error {
	userID := actorUserID(c)
	if len(userID) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	rosterID, err := h.findLatestRosterIDByUser(c.UserContext(), userID, "hems")
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if len(rosterID) != 16 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "hems duty roster not found",
		}})
	}
	return h.respondByID(c, fiber.StatusOK, rosterID)
}

// GetRosterByAuthUser godoc
// @Summary Get latest roster for current auth user (HEMS/Base)
// @Description Resolve latest roster linked to the authenticated user from flight assignments.
// @Tags Flights - Duty Roster
// @Produce json
// @Success 200 {object} jsonapi.Document
// @Failure 401 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Failure 500 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/duty-roster/get
func (h *DutyRosterHandler) GetRosterByAuthUser(c *fiber.Ctx) error {
	userID := actorUserID(c)
	if len(userID) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	if h.flight == nil {
		return writeDutyRosterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Get failed",
			Detail: "flight service is not configured",
		}})
	}

	rows, _, err := h.flight.ListByUserID(c.UserContext(), userID, 100, 0)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}

	rosterIDs := make([][]byte, 0, len(rows))
	seenRoster := make(map[string]struct{}, len(rows))
	flightByRoster := make(map[string]rosterFlightMeta, len(rows))
	for i := range rows {
		if len(rows[i].DutyRosterID) != 16 {
			continue
		}
		rosterKey := idString(rows[i].DutyRosterID)
		if rosterKey == "" {
			continue
		}
		if _, seen := seenRoster[rosterKey]; !seen {
			seenRoster[rosterKey] = struct{}{}
			rosterIDs = append(rosterIDs, append([]byte(nil), rows[i].DutyRosterID...))
		}
		next := rosterFlightMeta{
			FlightID:   idString(rows[i].ID),
			Helicopter: buildRosterHelicopter(rows[i]),
		}
		prev, exists := flightByRoster[rosterKey]
		if !exists || shouldReplaceRosterFlightMeta(prev, next) {
			flightByRoster[rosterKey] = next
		}
	}
	if len(rosterIDs) == 0 {
		return response.WriteWithMeta(c, fiber.StatusOK, []any{}, map[string]any{"total": 0})
	}

	crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), rosterIDs)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	crewsByRoster := map[string][]dutyroster.CrewView{}
	for i := range crews {
		k := idString(crews[i].RosterID)
		if k == "" {
			continue
		}
		crewsByRoster[k] = append(crewsByRoster[k], crews[i])
	}

	data := make([]any, 0, len(rosterIDs))
	for i := range rosterIDs {
		rosterID := rosterIDs[i]
		rosterKey := idString(rosterID)
		rosterCrews := crewsByRoster[rosterKey]

		headerHEMS, getErr := h.svc.GetHeaderByID(c.UserContext(), rosterID)
		if getErr != nil {
			status, errObj := buildServiceErrorObject("Get failed", getErr, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		if headerHEMS != nil {
			resource := dutyRosterResource(c.UserContext(), *headerHEMS, rosterCrews, "hems")
			if flightMeta, ok := flightByRoster[rosterKey]; ok {
				if strings.TrimSpace(flightMeta.FlightID) != "" {
					resource.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID)
				}
				if !isEmptyRosterHelicopter(flightMeta.Helicopter) {
					resource.Attributes.Helicopter = flightMeta.Helicopter
				}
			}
			data = append(data, resource)
			continue
		}

		headerBase, getErr := h.svc.GetHeaderByIDByBaseType(c.UserContext(), rosterID, "base")
		if getErr != nil {
			status, errObj := buildServiceErrorObject("Get failed", getErr, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		if headerBase != nil {
			resource := dutyRosterResource(c.UserContext(), *headerBase, rosterCrews, "base")
			if flightMeta, ok := flightByRoster[rosterKey]; ok {
				if strings.TrimSpace(flightMeta.FlightID) != "" {
					resource.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID)
				}
				if !isEmptyRosterHelicopter(flightMeta.Helicopter) {
					resource.Attributes.Helicopter = flightMeta.Helicopter
				}
			}
			data = append(data, resource)
		}
	}

	return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{"total": len(data)})
}

// GetAllRosters godoc
// @Summary List all rosters (HEMS + Base)
// @Description Combined roster list across HEMS and Base for date range and optional base filters.
// @Tags Flights - Duty Roster
// @Produce json
// @Param hems_base_id query string false "HEMS base UUID"
// @Param base_id query string false "Base UUID"
// @Param base_name query string false "Filter by base name (contains, case-insensitive)"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Success 200 {object} jsonapi.Document
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/get-all [get]
func (h *DutyRosterHandler) GetAllRosters(c *fiber.Ctx) error {
	hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id"))
	baseRaw := strings.TrimSpace(c.Query("base_id"))
	baseName := strings.TrimSpace(c.Query("base_name"))

	var hemsBaseID []byte
	if hemsBaseRaw != "" {
		parsed, err := uuidv7.ParseString(hemsBaseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "hems_base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"},
			}})
		}
		hemsBaseID = parsed
	}

	var baseID []byte
	if baseRaw != "" {
		parsed, err := uuidv7.ParseString(baseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"},
			}})
		}
		baseID = parsed
	}

	from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC())
	if rangeErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: rangeErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer},
		}})
	}
	if to.Before(from) {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "to must be >= from",
			Source: &jsonapi.ErrorSource{Pointer: "/query/to"},
		}})
	}

	hemsGroups, err := loadRosterGroups(c.UserContext(), h.svc, hemsBaseID, from, to, baseName, "hems")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToHEMSGroups(c.UserContext(), hemsGroups)

	baseGroups, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToBaseGroups(c.UserContext(), baseGroups)

	return response.WriteWithMeta(c, fiber.StatusOK, map[string]any{
		"hems": hemsGroups,
		"base": baseGroups,
	}, map[string]any{
		"from":       from.UTC().Format("2006-01-02"),
		"to":         to.UTC().Format("2006-01-02"),
		"timezone":   "UTC",
		"total_hems": len(hemsGroups),
		"total_base": len(baseGroups),
		"total":      len(hemsGroups) + len(baseGroups),
	})
}

func (h *DutyRosterHandler) GetByFlightID(c *fiber.Ctx) error {
	flightID, err := uuidv7.ParseString(strings.TrimSpace(dutyRosterPathFlightID(c)))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "flight_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/flight_id"},
		}})
	}
	if h.flight == nil {
		return writeDutyRosterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Get failed",
			Detail: "flight service is not configured",
		}})
	}

	flightRow, err := h.flight.GetByID(c.UserContext(), flightID)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if flightRow == nil {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "flight not found",
		}})
	}
	if len(flightRow.DutyRosterID) != 16 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "duty roster not linked to flight",
		}})
	}

	// Try HEMS format first.
	header, err := h.svc.GetHeaderByID(c.UserContext(), flightRow.DutyRosterID)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if header != nil {
		return h.respondByID(c, fiber.StatusOK, flightRow.DutyRosterID)
	}

	// Fallback to base format.
	return h.respondByIDBaseType(c, fiber.StatusOK, flightRow.DutyRosterID, "base")
}

// UpdateDutyRoster godoc
// @Summary Update HEMS duty roster (partial)
// @Description Update header fields and/or append/replace crew assignment by role+crew_type+user.
// @Description `data.id` is optional; if provided it must match path `uuid`.
// @Description If `roster_detail` is omitted, only header is updated.
// @Description Crew update is additive by default; same role+crew_type+user in same roster is replaced.
// @Description For pilot rows, extra flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied for `crew_type=additional`.
// @Description `data.type` should be `duty_roster_update` (or `duty_roster`). Legacy `hems_duty_roster_update`/`hems_duty_roster` is still accepted for backward compatibility.
// @Tags Flights - HEMS Duty Roster
// @Accept json
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload"
// @Success 200 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/update/{uuid}
func (h *DutyRosterHandler) Update(c *fiber.Ctx) error {
	var req requestdto.DutyRosterUpdateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	uuidStr := strings.TrimSpace(dutyRosterPathID(c))
	bodyID := strings.TrimSpace(req.Data.ID)
	if uuidStr == "" && bodyID == "" {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	if uuidStr != "" && bodyID != "" && bodyID != uuidStr {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id does not match path id",
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	targetID := bodyID
	if targetID == "" {
		targetID = uuidStr
	}
	id, err := h.resolveDutyRosterIDFromInput(c, targetID)
	if err != nil {
		sourcePtr := "/data/id"
		if targetID == uuidStr && uuidStr != "" && bodyID == "" {
			sourcePtr = "/path/id"
		}
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: safeInternalDetail(),
			Source: &jsonapi.ErrorSource{Pointer: sourcePtr},
		}})
	}

	existing, err := h.svc.GetHeaderByID(c.UserContext(), id)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if existing == nil {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "hems duty roster not found",
		}})
	}

	row := &dutyroster.DutyRoster{ID: id, BaseID: append([]byte(nil), existing.BaseID...), DutyDate: existing.DutyDate, UpdatedBy: actorUserID(c)}
	attrs := req.Data.Attributes
	if attrs.BaseID != nil && strings.TrimSpace(*attrs.BaseID) != "" {
		baseID, err := uuidv7.ParseString(strings.TrimSpace(*attrs.BaseID))
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "hems_base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/hems_base_id"},
			}})
		}
		row.BaseID = baseID
	}
	if attrs.DutyDate != nil && strings.TrimSpace(*attrs.DutyDate) != "" {
		d, err := service.ParseDutyRosterDateOnly(*attrs.DutyDate)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "duty_date must be YYYY-MM-DD",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"},
			}})
		}
		row.DutyDate = d
	}
	if attrs.ShiftTime != nil {
		defaultShiftStart, defaultShiftEnd, err := service.ResolveDutyRosterShiftDefaultsForUpdate(c.UserContext(), h.svc, row.BaseID, "hems", "", "")
		if err != nil {
			status, errObj := buildServiceErrorObject("Get failed", err, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		shiftStart, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start")
		if shiftErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: shiftErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
			}})
		}
		shiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end")
		if shiftErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: shiftErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
			}})
		}
		_ = shiftStart
		_ = shiftEnd
	}
	crews := []dutyroster.DutyRosterCrew{}
	if detail := toCoreDutyRosterDetailsInputPtr(attrs.Detail); detail != nil {
		built, crewErr := service.BuildDutyRosterCrewRows(*detail, actorUserID(c))
		if crewErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: crewErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer},
			}})
		}
		crews = built
	}
	if err := h.svc.Update(c.UserContext(), row, crews); err != nil {
		status, errObj := buildServiceErrorObject("Update failed", err, "hems duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.logCrewAudit(c, "duty_roster.crew.append", id, "hems", crews, "append crew assignments")
	return h.respondByID(c, fiber.StatusOK, id)
}

// DeleteDutyRoster godoc
// @Summary Delete HEMS duty roster
// @Description Delete one roster header and all attached crews by roster UUID.
// @Tags Flights - HEMS Duty Roster
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Success 200 {object} dto.GenericDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/delete/{uuid}
func (h *DutyRosterHandler) Delete(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}
	if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
		status, errObj := buildServiceErrorObject("Delete failed", err, "hems duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_delete", Attributes: map[string]any{"deleted": true}})
}

// DeleteDutyRosterCrew godoc
// @Summary Remove crew assignment from HEMS duty roster
// @Description Remove selected crew rows from roster UUID by filter in `roster_detail`.
// @Description This endpoint removes crew only; header roster stays.
// @Description For operational roles (pilot/doctor/rescuer/helper), if one `shift_date` boundary is sent then both are required.
// @Description If `shift_date` is omitted, filter applies to roster day only.
// @Description `data.type` should be `duty_roster_crew_delete` (also accepts `duty_roster_update`/`duty_roster` and legacy `hems_*` variants).
// @Tags Flights - HEMS Duty Roster
// @Accept json
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Param request body requestdto.DutyRosterDeleteCrewRequest true "Crew delete filter payload (use `roster_detail`)"
// @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/{uuid}/crew
func (h *DutyRosterHandler) DeleteCrew(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}
	crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c))
	if parseErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: parseErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer},
		}})
	}
	if len(crews) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "roster_detail is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"},
		}})
	}
	deletedRows, err := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c))
	if err != nil {
		status, errObj := buildServiceErrorObject("Delete failed", err, "hems duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if len(deletedRows) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "no crew assignment matched delete filter",
		}})
	}
	h.logCrewAudit(c, "duty_roster.crew.remove", id, "hems", crews, "remove crew assignments")
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}})
}

// ListDutyRoster godoc
// @Summary List HEMS duty roster
// @Description Grouped by base. Includes empty bases with `rosters: []`.
// @Description Query: `hems_base_id` (optional), `base_name` (optional), `from` and `to` (`YYYY-MM-DD`, optional).
// @Description Response grouping key is `attributes.hems_bases`.
// @Tags Flights - HEMS Duty Roster
// @Produce json
// @Param hems_base_id query string false "HEMS base UUID"
// @Param base_name query string false "Filter by HEMS base name (contains, case-insensitive)"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Success 200 {object} responsedto.DutyRosterGroupedListResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/get-all
func (h *DutyRosterHandler) List(c *fiber.Ctx) error {
	hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id"))
	var baseID []byte
	var err error
	if hemsBaseRaw != "" {
		baseID, err = uuidv7.ParseString(hemsBaseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "hems_base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"},
			}})
		}
	}
	from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC())
	if rangeErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: rangeErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer},
		}})
	}
	if to.Before(from) {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "to must be >= from",
			Source: &jsonapi.ErrorSource{Pointer: "/query/to"},
		}})
	}
	baseName := strings.TrimSpace(c.Query("base_name"))

	grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "hems")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToHEMSGroups(c.UserContext(), grouped)
	return response.WriteWithMeta(c, fiber.StatusOK, grouped, map[string]any{"from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC", "total_bases": len(grouped)})
}

// ListDutyRosterDatatable godoc
// @Summary List HEMS duty roster (datatable)
// @Description Datatable format with grouped rows and pagination metadata.
// @Description Query: `hems_base_id`, `base_name`, `from`, `to`, `page`, `size`, `limit`, `draw`.
// @Description Response grouping key is `attributes.hems_bases`.
// @Tags Flights - HEMS Duty Roster
// @Produce json
// @Param hems_base_id query string false "HEMS base UUID"
// @Param base_name query string false "Filter by HEMS base name (contains, case-insensitive)"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Param page query int false "Page number"
// @Param size query int false "Page size"
// @Param limit query int false "Page size override"
// @Param draw query int false "Draw counter"
// @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/hems-roster/get-all/dt
func (h *DutyRosterHandler) ListDatatable(c *fiber.Ctx) error {
	hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id"))
	var baseID []byte
	var err error
	if hemsBaseRaw != "" {
		baseID, err = uuidv7.ParseString(hemsBaseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "hems_base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"},
			}})
		}
	}
	from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC())
	if rangeErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: rangeErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer},
		}})
	}
	if to.Before(from) {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "to must be >= from",
			Source: &jsonapi.ErrorSource{Pointer: "/query/to"},
		}})
	}
	baseName := strings.TrimSpace(c.Query("base_name"))

	pageNumber := parseIntDefault(c.Query("page"), 1)
	if pageNumber < 1 {
		pageNumber = 1
	}
	length := parseIntDefault(c.Query("limit"), 0)
	if length <= 0 {
		length = parseIntDefault(c.Query("size"), 20)
	}
	if length > 100 {
		length = 100
	}
	if length < 1 {
		length = 20
	}
	start := (pageNumber - 1) * length
	draw := parseIntDefault(c.Query("draw"), pageNumber)

	grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "hems")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToHEMSGroups(c.UserContext(), grouped)
	pagedGroups, total := paginateRosterGroups(grouped, start, length)
	meta := map[string]any{"draw": draw, "records_total": total, "records_filtered": total, "from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC"}
	return response.WriteWithMeta(c, fiber.StatusOK, pagedGroups, meta)
}

// CreateDutyRosterUnified godoc
// @Summary Create duty roster
// @Description Unified endpoint for all base categories.
// @Tags Flights - Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterCreateRequest true "Create request payload"
// @Success 201 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/create [post]
func (h *DutyRosterHandler) CreateAny(c *fiber.Ctx) error {
	var req requestdto.DutyRosterCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "base_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
		}})
	}
	baseType, err := h.resolveBaseTypeByBaseID(c.UserContext(), baseID)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "base_id not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if baseType == "base" {
		return h.CreateBase(c)
	}
	return h.Create(c)
}

// ListDutyRosterUnifiedDatatable godoc
// @Summary List duty roster (datatable)
// @Description Unified datatable endpoint. Optional `base_type` filter: `hems` or `base`.
// @Tags Flights - Duty Roster
// @Produce json
// @Param base_type query string false "Base type filter (hems|base)"
// @Param base_id query string false "Base UUID"
// @Param base_name query string false "Filter by base name"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Param page query int false "Page number"
// @Param size query int false "Page size"
// @Param limit query int false "Page size override"
// @Param draw query int false "Draw counter"
// @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/get-all/dt [get]
func (h *DutyRosterHandler) ListAnyDatatable(c *fiber.Ctx) error {
	if strings.EqualFold(strings.TrimSpace(c.Query("base_type")), "base") {
		return h.ListBaseDatatable(c)
	}
	return h.ListDatatable(c)
}

// DeleteDutyRosterUnified godoc
// @Summary Delete duty roster
// @Description Unified delete endpoint by `data.id`.
// @Tags Flights - Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterAnyDeleteRequest true "Delete request payload"
// @Success 200 {object} dto.GenericDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/delete [delete]
func (h *DutyRosterHandler) DeleteAny(c *fiber.Ctx) error {
	var req struct {
		Data struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	id, err := h.resolveDutyRosterIDFromInput(c, strings.TrimSpace(req.Data.ID))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: safeInternalDetail(),
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
		status, errObj := buildServiceErrorObject("Delete failed", err, "duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_delete", Attributes: map[string]any{"deleted": true}})
}

// DeleteDutyRosterUnifiedCrew godoc
// @Summary Remove duty roster crew assignment
// @Description Unified crew delete endpoint by `data.id` and `roster_detail`.
// @Tags Flights - Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterDeleteCrewByIDRequest true "Crew delete request payload"
// @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/crew [delete]
func (h *DutyRosterHandler) DeleteAnyCrew(c *fiber.Ctx) error {
	var req struct {
		Data struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	id, err := h.resolveDutyRosterIDFromInput(c, strings.TrimSpace(req.Data.ID))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: safeInternalDetail(),
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c))
	if parseErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: parseErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer},
		}})
	}
	if len(crews) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "roster_detail is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"},
		}})
	}
	deletedRows, delErr := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c))
	if delErr != nil {
		status, errObj := buildServiceErrorObject("Delete failed", delErr, "duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if len(deletedRows) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "no crew assignment matched delete filter",
		}})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}})
}

// CreateBaseDutyRoster godoc
// @Summary Create Base duty roster
// @Description Create roster by regular `base_id` and `duty_date`.
// @Description Base endpoints use `base_id` in request and `bases` in response attributes.
// @Description Time input accepts `HH:MM` or `HH:MM:SS` and is normalized.
// @Description `roster_detail` buckets: `pilot`, `doctor`, `rescuer`, `helper`, `other_person`.
// @Description For pilot: `crew_type` can be `main` or `additional`.
// @Description Pilot flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied when `crew_type=additional`; for `main` they are ignored.
// @Description For doctor/rescuer/helper, `crew_type` is always treated as `main`.
// @Description For `other_person`, `id` must be empty and `name` is required.
// @Description If roster shift_time is omitted, system uses base default shift; fallback `06:00:00-21:00:00`.
// @Description `data.type` must be `base_duty_roster_create` (or `base_duty_roster` for backward compatibility).
// @Tags Flights - Base Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterCreateRequest true "Create request payload"
// @Success 201 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/create
func (h *DutyRosterHandler) CreateBase(c *fiber.Ctx) error {
	var req requestdto.DutyRosterCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs)
	}

	baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "base_id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
		}})
	}
	dutyDate, err := service.ParseDutyRosterDateOnly(req.Data.Attributes.DutyDate)
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "duty_date must be YYYY-MM-DD",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"},
		}})
	}

	row := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: dutyDate, CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c)}
	defaultShiftStart, defaultShiftEnd, err := h.svc.ResolveBaseDefaultShiftRange(c.UserContext(), baseID, "base")
	if err != nil {
		if wrote := h.writeBaseTypeValidationError(c, err, baseID, "base", "/data/attributes/base_id"); wrote {
			return nil
		}
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	rowShiftStart, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start")
	if shiftErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: shiftErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
		}})
	}
	rowShiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end")
	if shiftErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: shiftErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
		}})
	}
	_ = rowShiftStart
	_ = rowShiftEnd
	detail := toCoreDutyRosterDetailsInput(req.Data.Attributes.Detail)
	crews, crewErr := service.BuildDutyRosterCrewRows(detail, actorUserID(c))
	if crewErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: crewErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer},
		}})
	}
	clearCrewShiftDateForCreate(crews)
	if err := h.svc.Create(c.UserContext(), row, crews); err != nil {
		status, errObj := buildServiceErrorObject("Create failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}

	flightID, err := h.createFlightForBaseRoster(c, row)
	if err != nil {
		_ = h.svc.HardDelete(c.UserContext(), row.ID)
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Create failed",
			Detail: safeInternalDetail(),
		}})
	}

	return h.respondByIDBaseTypeWithFlight(c, fiber.StatusCreated, row.ID, "base", flightID)
}

func (h *DutyRosterHandler) createFlightForBaseRoster(c *fiber.Ctx, roster *dutyroster.DutyRoster) ([]byte, error) {
	if h.flight == nil {
		return nil, nil
	}

	flightRow := &flight.Flight{
		Date:         roster.DutyDate.UTC(),
		DutyRosterID: append([]byte(nil), roster.ID...),
		CreatedBy:    actorUserID(c),
		UpdatedBy:    actorUserID(c),
	}

	if err := h.flight.Create(c.UserContext(), flightRow); err != nil {
		return nil, err
	}
	return flightRow.ID, nil
}

// GetBaseDutyRoster godoc
// @Summary Get Base duty roster by UUID
// @Tags Flights - Base Duty Roster
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Success 200 {object} responsedto.DutyRosterResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 422 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/get/{uuid}
func (h *DutyRosterHandler) GetBase(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}

	// Backward-compatible fallback: allow :uuid as flight_uuid.
	if h.flight != nil {
		flightRow, flightErr := h.flight.GetByID(c.UserContext(), id)
		if flightErr != nil {
			status, errObj := buildServiceErrorObject("Get failed", flightErr, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		if flightRow != nil && len(flightRow.DutyRosterID) == 16 {
			return h.respondByIDBaseTypeWithFlight(c, fiber.StatusOK, flightRow.DutyRosterID, "base", flightRow.ID)
		}
	}

	return h.respondByIDBaseType(c, fiber.StatusOK, id, "base")
}

func (h *DutyRosterHandler) GetBaseByAuthUser(c *fiber.Ctx) error {
	userID := actorUserID(c)
	if len(userID) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{
			Status: "401",
			Title:  "Unauthorized",
			Detail: "missing auth context",
		}})
	}
	rosterID, err := h.findLatestRosterIDByUser(c.UserContext(), userID, "base")
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if len(rosterID) != 16 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "base duty roster not found",
		}})
	}
	return h.respondByIDBaseType(c, fiber.StatusOK, rosterID, "base")
}

// UpdateBaseDutyRoster godoc
// @Summary Update Base duty roster (partial)
// @Description If `roster_detail` is omitted, only header is updated.
// @Description Crew update is additive by default; same role+crew_type+user in same roster is replaced.
// @Description For pilot rows, extra flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied for `crew_type=additional`.
// @Description `data.type` must be `base_duty_roster_update` (or `base_duty_roster` for backward compatibility).
// @Tags Flights - Base Duty Roster
// @Accept json
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload"
// @Success 200 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/update/{uuid}
func (h *DutyRosterHandler) UpdateBase(c *fiber.Ctx) error {
	var req requestdto.DutyRosterUpdateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	uuidStr := strings.TrimSpace(dutyRosterPathID(c))
	bodyID := strings.TrimSpace(req.Data.ID)
	if uuidStr == "" && bodyID == "" {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	if uuidStr != "" && bodyID != "" && bodyID != uuidStr {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id does not match path id",
			Source: &jsonapi.ErrorSource{Pointer: "/data/id"},
		}})
	}
	targetID := bodyID
	if targetID == "" {
		targetID = uuidStr
	}
	id, err := h.resolveDutyRosterIDFromInput(c, targetID)
	if err != nil {
		sourcePtr := "/data/id"
		if targetID == uuidStr && uuidStr != "" && bodyID == "" {
			sourcePtr = "/path/id"
		}
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: safeInternalDetail(),
			Source: &jsonapi.ErrorSource{Pointer: sourcePtr},
		}})
	}

	existing, err := h.svc.GetHeaderByIDByBaseType(c.UserContext(), id, "base")
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if existing == nil {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "base duty roster not found",
		}})
	}

	row := &dutyroster.DutyRoster{ID: id, BaseID: append([]byte(nil), existing.BaseID...), DutyDate: existing.DutyDate, UpdatedBy: actorUserID(c)}
	attrs := req.Data.Attributes
	if attrs.BaseID != nil && strings.TrimSpace(*attrs.BaseID) != "" {
		baseID, err := uuidv7.ParseString(strings.TrimSpace(*attrs.BaseID))
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
			}})
		}
		row.BaseID = baseID
	}
	if attrs.DutyDate != nil && strings.TrimSpace(*attrs.DutyDate) != "" {
		d, err := service.ParseDutyRosterDateOnly(*attrs.DutyDate)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "duty_date must be YYYY-MM-DD",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"},
			}})
		}
		row.DutyDate = d
	}
	if attrs.ShiftTime != nil {
		defaultShiftStart, defaultShiftEnd, err := service.ResolveDutyRosterShiftDefaultsForUpdate(c.UserContext(), h.svc, row.BaseID, "base", "", "")
		if err != nil {
			status, errObj := buildServiceErrorObject("Get failed", err, "")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		shiftStart, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start")
		if shiftErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: shiftErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
			}})
		}
		shiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end")
		if shiftErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: shiftErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer},
			}})
		}
		_ = shiftStart
		_ = shiftEnd
	}
	crews := []dutyroster.DutyRosterCrew{}
	if detail := toCoreDutyRosterDetailsInputPtr(attrs.Detail); detail != nil {
		built, crewErr := service.BuildDutyRosterCrewRows(*detail, actorUserID(c))
		if crewErr != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: crewErr.Detail,
				Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer},
			}})
		}
		crews = built
	}
	if err := h.svc.Update(c.UserContext(), row, crews); err != nil {
		status, errObj := buildServiceErrorObject("Update failed", err, "base duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.logCrewAudit(c, "duty_roster.crew.append", id, "base", crews, "append crew assignments")
	return h.respondByIDBaseType(c, fiber.StatusOK, id, "base")
}

// UpdateDutyRosterUnified godoc
// @Summary Update duty roster (partial)
// @Description Unified update endpoint by `data.id`.
// @Tags Flights - Duty Roster
// @Accept json
// @Produce json
// @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload"
// @Success 200 {object} responsedto.DutyRosterResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/duty-roster/update [patch]
func (h *DutyRosterHandler) UpdateAny(c *fiber.Ctx) error {
	var req struct {
		Data struct {
			Type       string `json:"type"`
			ID         string `json:"id"`
			Attributes struct {
				BaseID *string `json:"base_id,omitempty"`
			} `json:"attributes"`
		} `json:"data"`
	}
	if err := c.BodyParser(&req); err != nil {
		return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	if req.Data.Attributes.BaseID != nil && strings.TrimSpace(*req.Data.Attributes.BaseID) != "" {
		baseID, err := uuidv7.ParseString(strings.TrimSpace(*req.Data.Attributes.BaseID))
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
			}})
		}
		baseType, err := h.resolveBaseTypeByBaseID(c.UserContext(), baseID)
		if err != nil {
			status, errObj := buildServiceErrorObject("Get failed", err, "base_id not found")
			return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		}
		if baseType == "base" {
			return h.UpdateBase(c)
		}
		return h.Update(c)
	}

	targetID := strings.TrimSpace(req.Data.ID)
	if targetID != "" {
		rosterID, err := h.resolveDutyRosterIDFromInput(c, targetID)
		if err == nil && len(rosterID) == 16 {
			if hdr, _ := h.svc.GetHeaderByIDByBaseType(c.UserContext(), rosterID, "base"); hdr != nil {
				return h.UpdateBase(c)
			}
			return h.Update(c)
		}
	}
	t := strings.ToLower(strings.TrimSpace(req.Data.Type))
	if strings.HasPrefix(t, "base_") {
		return h.UpdateBase(c)
	}
	return h.Update(c)
}

func (h *DutyRosterHandler) resolveDutyRosterIDFromInput(c *fiber.Ctx, rawID string) ([]byte, error) {
	parsedID, err := uuidv7.ParseString(strings.TrimSpace(rawID))
	if err != nil {
		return nil, errors.New("id is invalid UUID")
	}

	if h.flight != nil {
		flightRow, flightErr := h.flight.GetByID(c.UserContext(), parsedID)
		if flightErr != nil {
			return nil, flightErr
		}
		if flightRow != nil {
			if len(flightRow.DutyRosterID) != 16 {
				return nil, errors.New("flight_id is not linked to duty roster")
			}
			return append([]byte(nil), flightRow.DutyRosterID...), nil
		}
	}

	// Backward compatibility: allow direct duty_roster_id.
	return parsedID, nil
}

func (h *DutyRosterHandler) findLatestRosterIDByUser(ctx context.Context, userID []byte, baseType string) ([]byte, error) {
	if h.flight == nil || len(userID) != 16 {
		return nil, nil
	}
	rows, _, err := h.flight.ListByUserID(ctx, userID, 100, 0)
	if err != nil {
		return nil, err
	}
	for i := range rows {
		if len(rows[i].DutyRosterID) != 16 {
			continue
		}
		header, err := h.svc.GetHeaderByIDByBaseType(ctx, rows[i].DutyRosterID, baseType)
		if err != nil {
			return nil, err
		}
		if header != nil {
			return append([]byte(nil), rows[i].DutyRosterID...), nil
		}
	}
	return nil, nil
}

// DeleteBaseDutyRoster godoc
// @Summary Delete Base duty roster
// @Tags Flights - Base Duty Roster
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Success 200 {object} dto.GenericDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/delete/{uuid}
func (h *DutyRosterHandler) DeleteBase(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}
	if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
		status, errObj := buildServiceErrorObject("Delete failed", err, "base duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "base_duty_roster_delete", Attributes: map[string]any{"deleted": true}})
}

// DeleteBaseDutyRosterCrew godoc
// @Summary Remove crew assignment from Base duty roster
// @Description Remove selected crew rows from roster UUID by filter in `roster_detail`.
// @Description This endpoint removes crew only; header roster stays.
// @Description For operational roles (pilot/doctor/rescuer/helper), if one `shift_date` boundary is sent then both are required.
// @Description If `shift_date` is omitted, filter applies to roster day only.
// @Description `data.type` should be `base_duty_roster_crew_delete` (also accepts `base_duty_roster_update`/`base_duty_roster` for compatibility).
// @Tags Flights - Base Duty Roster
// @Accept json
// @Produce json
// @Param uuid path string true "Roster UUID"
// @Param request body requestdto.DutyRosterDeleteCrewRequest true "Crew delete filter payload (use `roster_detail`)"
// @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/{uuid}/crew
func (h *DutyRosterHandler) DeleteBaseCrew(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(dutyRosterPathID(c))
	if err != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "id is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
		}})
	}
	crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c))
	if parseErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: parseErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer},
		}})
	}
	if len(crews) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "roster_detail is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"},
		}})
	}
	deletedRows, err := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c))
	if err != nil {
		status, errObj := buildServiceErrorObject("Delete failed", err, "base duty roster not found")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if len(deletedRows) == 0 {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "no crew assignment matched delete filter",
		}})
	}
	h.logCrewAudit(c, "duty_roster.crew.remove", id, "base", crews, "remove crew assignments")
	return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "base_duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}})
}

// ListBaseDutyRoster godoc
// @Summary List Base duty roster
// @Description Query: `base_id` (optional), `base_name` (optional), `from` and `to` (`YYYY-MM-DD`, optional).
// @Description Response grouping key is `attributes.bases`.
// @Tags Flights - Base Duty Roster
// @Produce json
// @Param base_id query string false "Base UUID"
// @Param base_name query string false "Filter by base name (contains, case-insensitive)"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Success 200 {object} responsedto.DutyRosterGroupedListResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/get-all
func (h *DutyRosterHandler) ListBase(c *fiber.Ctx) error {
	baseRaw := strings.TrimSpace(c.Query("base_id"))
	var baseID []byte
	var err error
	if baseRaw != "" {
		baseID, err = uuidv7.ParseString(baseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"},
			}})
		}
	}
	from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC())
	if rangeErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: rangeErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer},
		}})
	}
	if to.Before(from) {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "to must be >= from",
			Source: &jsonapi.ErrorSource{Pointer: "/query/to"},
		}})
	}
	baseName := strings.TrimSpace(c.Query("base_name"))

	grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToBaseGroups(c.UserContext(), grouped)
	return response.WriteWithMeta(c, fiber.StatusOK, grouped, map[string]any{"from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC", "total_bases": len(grouped)})
}

// ListBaseDutyRosterDatatable godoc
// @Summary List Base duty roster (datatable)
// @Description Query: `base_id`, `base_name`, `from`, `to`, `page`, `size`, `limit`, `draw`.
// @Description Response grouping key is `attributes.bases`.
// @Tags Flights - Base Duty Roster
// @Produce json
// @Param base_id query string false "Base UUID"
// @Param base_name query string false "Filter by base name (contains, case-insensitive)"
// @Param from query string false "Start date (YYYY-MM-DD)"
// @Param to query string false "End date (YYYY-MM-DD)"
// @Param page query int false "Page number"
// @Param size query int false "Page size"
// @Param limit query int false "Page size override"
// @Param draw query int false "Draw counter"
// @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// Legacy docs route removed: /api/v1/base-roster/get-all/dt
func (h *DutyRosterHandler) ListBaseDatatable(c *fiber.Ctx) error {
	baseRaw := strings.TrimSpace(c.Query("base_id"))
	var baseID []byte
	var err error
	if baseRaw != "" {
		baseID, err = uuidv7.ParseString(baseRaw)
		if err != nil {
			return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"},
			}})
		}
	}
	from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC())
	if rangeErr != nil {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: rangeErr.Detail,
			Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer},
		}})
	}
	if to.Before(from) {
		return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "to must be >= from",
			Source: &jsonapi.ErrorSource{Pointer: "/query/to"},
		}})
	}
	baseName := strings.TrimSpace(c.Query("base_name"))

	pageNumber := parseIntDefault(c.Query("page"), 1)
	if pageNumber < 1 {
		pageNumber = 1
	}
	length := parseIntDefault(c.Query("limit"), 0)
	if length <= 0 {
		length = parseIntDefault(c.Query("size"), 20)
	}
	if length > 100 {
		length = 100
	}
	if length < 1 {
		length = 20
	}
	start := (pageNumber - 1) * length
	draw := parseIntDefault(c.Query("draw"), pageNumber)

	grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base")
	if err != nil {
		status, errObj := buildServiceErrorObject("List failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	h.attachFlightIDsToBaseGroups(c.UserContext(), grouped)
	pagedGroups, total := paginateRosterGroups(grouped, start, length)
	meta := map[string]any{"draw": draw, "records_total": total, "records_filtered": total, "from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC"}
	return response.WriteWithMeta(c, fiber.StatusOK, pagedGroups, meta)
}

func (h *DutyRosterHandler) respondByIDBaseType(c *fiber.Ctx, status int, id []byte, baseType string) error {
	return h.respondByIDBaseTypeWithFlight(c, status, id, baseType, nil)
}

func (h *DutyRosterHandler) respondByIDBaseTypeWithFlight(c *fiber.Ctx, status int, id []byte, baseType string, flightID []byte) error {
	header, err := h.svc.GetHeaderByIDByBaseType(c.UserContext(), id, baseType)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if header == nil {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "duty roster not found",
		}})
	}
	crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), [][]byte{id})
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	flightMeta := rosterFlightMeta{}
	if len(flightID) == 16 {
		flightMeta.FlightID = idString(flightID)
	} else {
		flightMeta, _ = h.findFlightMetaByRosterID(c.UserContext(), id)
	}
	if strings.EqualFold(strings.TrimSpace(baseType), "base") {
		res := dutyRosterResource(c.UserContext(), *header, crews, "base")
		if strings.TrimSpace(flightMeta.FlightID) != "" {
			res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID)
		}
		if !isEmptyRosterHelicopter(flightMeta.Helicopter) {
			res.Attributes.Helicopter = flightMeta.Helicopter
		}
		return response.Write(c, status, res)
	}
	res := dutyRosterResource(c.UserContext(), *header, crews, "hems")
	if strings.TrimSpace(flightMeta.FlightID) != "" {
		res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID)
	}
	if !isEmptyRosterHelicopter(flightMeta.Helicopter) {
		res.Attributes.Helicopter = flightMeta.Helicopter
	}
	return response.Write(c, status, res)
}

func (h *DutyRosterHandler) respondByID(c *fiber.Ctx, status int, id []byte) error {
	header, err := h.svc.GetHeaderByID(c.UserContext(), id)
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	if header == nil {
		return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "hems duty roster not found",
		}})
	}
	crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), [][]byte{id})
	if err != nil {
		status, errObj := buildServiceErrorObject("Get failed", err, "")
		return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
	}
	res := dutyRosterResource(c.UserContext(), *header, crews, "hems")
	if flightMeta, findErr := h.findFlightMetaByRosterID(c.UserContext(), id); findErr == nil {
		if strings.TrimSpace(flightMeta.FlightID) != "" {
			res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID)
		}
		if !isEmptyRosterHelicopter(flightMeta.Helicopter) {
			res.Attributes.Helicopter = flightMeta.Helicopter
		}
	}
	return response.Write(c, status, res)
}

func (h *DutyRosterHandler) findFlightMetaByRosterID(ctx context.Context, rosterID []byte) (rosterFlightMeta, error) {
	if h.flight == nil || len(rosterID) != 16 {
		return rosterFlightMeta{}, nil
	}
	row, err := h.flight.GetByDutyRosterID(ctx, rosterID)
	if err != nil || row == nil {
		return rosterFlightMeta{}, err
	}
	return rosterFlightMeta{
		FlightID:   idString(row.ID),
		Helicopter: buildRosterHelicopter(*row),
	}, nil
}

func (h *DutyRosterHandler) buildFlightMetaByRosterMap(ctx context.Context, rosterIDs [][]byte) map[string]rosterFlightMeta {
	out := map[string]rosterFlightMeta{}
	if h.flight == nil || len(rosterIDs) == 0 {
		return out
	}
	rows, err := h.flight.ListByDutyRosterIDs(ctx, rosterIDs)
	if err != nil {
		return out
	}
	for i := range rows {
		rosterKey := idString(rows[i].DutyRosterID)
		if rosterKey == "" {
			continue
		}
		next := rosterFlightMeta{
			FlightID:   idString(rows[i].ID),
			Helicopter: buildRosterHelicopter(rows[i]),
		}
		prev, exists := out[rosterKey]
		if !exists || shouldReplaceRosterFlightMeta(prev, next) {
			out[rosterKey] = next
		}
	}
	return out
}

func (h *DutyRosterHandler) attachFlightIDsToHEMSGroups(ctx context.Context, groups []responsedto.DutyRosterBaseGroupResource) {
	rosterIDs := make([][]byte, 0)
	for i := range groups {
		for j := range groups[i].Attributes.Rosters {
			id, err := uuidv7.ParseString(strings.TrimSpace(groups[i].Attributes.Rosters[j].ID))
			if err == nil {
				rosterIDs = append(rosterIDs, id)
			}
		}
	}
	flightByRoster := h.buildFlightMetaByRosterMap(ctx, rosterIDs)
	for i := range groups {
		for j := range groups[i].Attributes.Rosters {
			rosterID := strings.TrimSpace(groups[i].Attributes.Rosters[j].ID)
			meta := flightByRoster[rosterID]
			if strings.TrimSpace(meta.FlightID) != "" {
				groups[i].Attributes.Rosters[j].FlightID = strings.TrimSpace(meta.FlightID)
			}
			if !isEmptyRosterHelicopter(meta.Helicopter) {
				groups[i].Attributes.Rosters[j].Helicopter = meta.Helicopter
			}
		}
	}
}

func (h *DutyRosterHandler) attachFlightIDsToBaseGroups(ctx context.Context, groups []responsedto.DutyRosterBaseGroupResource) {
	rosterIDs := make([][]byte, 0)
	for i := range groups {
		for j := range groups[i].Attributes.Rosters {
			id, err := uuidv7.ParseString(strings.TrimSpace(groups[i].Attributes.Rosters[j].ID))
			if err == nil {
				rosterIDs = append(rosterIDs, id)
			}
		}
	}
	flightByRoster := h.buildFlightMetaByRosterMap(ctx, rosterIDs)
	for i := range groups {
		for j := range groups[i].Attributes.Rosters {
			rosterID := strings.TrimSpace(groups[i].Attributes.Rosters[j].ID)
			meta := flightByRoster[rosterID]
			if strings.TrimSpace(meta.FlightID) != "" {
				groups[i].Attributes.Rosters[j].FlightID = strings.TrimSpace(meta.FlightID)
			}
			if !isEmptyRosterHelicopter(meta.Helicopter) {
				groups[i].Attributes.Rosters[j].Helicopter = meta.Helicopter
			}
		}
	}
}

func buildRosterHelicopter(row flight.Flight) responsedto.DutyRosterHelicopterSummary {
	if row.Takeover == nil {
		return responsedto.DutyRosterHelicopterSummary{}
	}
	return responsedto.DutyRosterHelicopterSummary{
		ID: idString(row.Takeover.ReserveAcID),
	}
}

func shouldReplaceRosterFlightMeta(prev, next rosterFlightMeta) bool {
	if isEmptyRosterHelicopter(prev.Helicopter) && !isEmptyRosterHelicopter(next.Helicopter) {
		return true
	}
	if !isEmptyRosterHelicopter(prev.Helicopter) && isEmptyRosterHelicopter(next.Helicopter) {
		return false
	}
	if strings.TrimSpace(prev.FlightID) == "" && strings.TrimSpace(next.FlightID) != "" {
		return true
	}
	return false
}

func isEmptyRosterHelicopter(h responsedto.DutyRosterHelicopterSummary) bool {
	return strings.TrimSpace(h.ID) == "" &&
		strings.TrimSpace(h.Designation) == "" &&
		strings.TrimSpace(h.Identifier) == "" &&
		strings.TrimSpace(h.Type) == ""
}

func dateToString(t *time.Time) string {
	if t == nil || t.IsZero() {
		return ""
	}
	return t.UTC().Format("2006-01-02")
}

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

func dutyRosterPathID(c *fiber.Ctx) string {
	id := strings.TrimSpace(c.Params("id"))
	if id != "" {
		return id
	}
	return strings.TrimSpace(c.Params("uuid"))
}

func dutyRosterPathFlightID(c *fiber.Ctx) string {
	id := strings.TrimSpace(c.Params("flight_id"))
	if id != "" {
		return id
	}
	return strings.TrimSpace(c.Params("flight_uuid"))
}

func writeDutyRosterErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
	enriched := make([]jsonapi.ErrorObject, 0, len(errs))
	statusStr := strconv.Itoa(status)
	for i := range errs {
		e := errs[i]
		e.Status = statusStr
		if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" {
			code, errorCode := inferDutyRosterErrorCode(status, e.Title, e.Detail)
			if strings.TrimSpace(e.Code) == "" {
				e.Code = code
			}
			if strings.TrimSpace(e.ErrorCode) == "" {
				e.ErrorCode = errorCode
			}
		}
		enriched = append(enriched, e)
	}
	return response.WriteErrors(c, status, enriched)
}

func inferDutyRosterErrorCode(status int, title, detail string) (string, string) {
	lowerTitle := strings.ToLower(strings.TrimSpace(title))
	lowerDetail := strings.ToLower(strings.TrimSpace(detail))
	switch status {
	case fiber.StatusUnauthorized:
		return apperrorsx.CodeDutyRosterUnauthorized, apperrorsx.ErrDutyRosterUnauthorized.ErrorCode
	case fiber.StatusNotFound:
		return apperrorsx.CodeDutyRosterNotFound, apperrorsx.ErrDutyRosterNotFound.ErrorCode
	case fiber.StatusConflict:
		if strings.Contains(lowerDetail, "cannot delete") || strings.Contains(lowerDetail, "relation") || strings.Contains(lowerDetail, "foreign key") || strings.Contains(lowerDetail, "constraint") {
			return apperrorsx.CodeDutyRosterRelationDeleteConflict, apperrorsx.ErrDutyRosterRelationDeleteConflict.ErrorCode
		}
		return apperrorsx.CodeDutyRosterConflict, apperrorsx.ErrDutyRosterConflict.ErrorCode
	case fiber.StatusUnprocessableEntity:
		switch {
		case strings.Contains(lowerDetail, "uuid"), strings.Contains(lowerDetail, "invalid id"):
			return apperrorsx.CodeDutyRosterInvalidUUID, apperrorsx.ErrDutyRosterInvalidUUID.ErrorCode
		case strings.Contains(lowerDetail, "base type"), strings.Contains(lowerDetail, "not hems base"), strings.Contains(lowerDetail, "not regular base"):
			return apperrorsx.CodeDutyRosterBaseTypeInvalid, apperrorsx.ErrDutyRosterBaseTypeInvalid.ErrorCode
		case strings.Contains(lowerTitle, "validation error"):
			return apperrorsx.CodeDutyRosterValidationFailed, apperrorsx.ErrDutyRosterValidationFailed.ErrorCode
		default:
			return apperrorsx.CodeDutyRosterInvalidPayload, apperrorsx.ErrDutyRosterInvalidPayload.ErrorCode
		}
	case fiber.StatusBadRequest:
		switch {
		case strings.Contains(lowerTitle, "invalid json"):
			return apperrorsx.CodeDutyRosterInvalidJSON, apperrorsx.ErrDutyRosterInvalidJSON.ErrorCode
		case strings.Contains(lowerTitle, "create failed"):
			return apperrorsx.CodeDutyRosterCreateFailed, apperrorsx.ErrDutyRosterCreateFailed.ErrorCode
		case strings.Contains(lowerTitle, "update failed"):
			return apperrorsx.CodeDutyRosterUpdateFailed, apperrorsx.ErrDutyRosterUpdateFailed.ErrorCode
		case strings.Contains(lowerTitle, "delete failed"):
			return apperrorsx.CodeDutyRosterDeleteFailed, apperrorsx.ErrDutyRosterDeleteFailed.ErrorCode
		case strings.Contains(lowerTitle, "list failed"):
			return apperrorsx.CodeDutyRosterListFailed, apperrorsx.ErrDutyRosterListFailed.ErrorCode
		default:
			return apperrorsx.CodeDutyRosterInvalidPayload, apperrorsx.ErrDutyRosterInvalidPayload.ErrorCode
		}
	default:
		return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
	}
}

func buildServiceErrorObject(title string, err error, notFoundDetail string) (int, jsonapi.ErrorObject) {
	if err == nil {
		status := fiber.StatusBadRequest
		return status, jsonapi.ErrorObject{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: title,
		}
	}
	detail := strings.TrimSpace(err.Error())

	if errors.Is(err, gorm.ErrRecordNotFound) {
		status := fiber.StatusNotFound
		if strings.TrimSpace(notFoundDetail) != "" {
			detail = strings.TrimSpace(notFoundDetail)
		} else if detail == "" {
			detail = "resource not found"
		}
		return status, jsonapi.ErrorObject{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: detail,
		}
	}

	if errors.Is(err, context.Canceled) {
		status := fiber.StatusRequestTimeout
		if detail == "" {
			detail = "request canceled"
		}
		return status, jsonapi.ErrorObject{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: detail,
		}
	}

	if errors.Is(err, context.DeadlineExceeded) {
		status := fiber.StatusRequestTimeout
		if detail == "" {
			detail = "request timeout"
		}
		return status, jsonapi.ErrorObject{
			Status: strconv.Itoa(status),
			Title:  title,
			Detail: detail,
		}
	}

	status, detail, mysqlMapped := mapDutyRosterMySQLError(err, detail)
	if !mysqlMapped {
		status, detail = mapDutyRosterGenericError(detail, title)
	}
	if detail == "" {
		detail = title
	}
	return status, jsonapi.ErrorObject{
		Status: strconv.Itoa(status),
		Title:  title,
		Detail: detail,
	}
}

func (h *DutyRosterHandler) writeBaseTypeValidationError(
	c *fiber.Ctx,
	err error,
	baseID []byte,
	expectedBaseType string,
	pointer string,
) bool {
	if !errors.Is(err, gorm.ErrRecordNotFound) {
		return false
	}

	var oppositeType string
	var mismatchDetail string
	switch strings.ToLower(strings.TrimSpace(expectedBaseType)) {
	case "base":
		oppositeType = "hems"
		mismatchDetail = "base_id is not regular base"
	default:
		oppositeType = "base"
		mismatchDetail = "base_id is not HEMS base"
	}

	rows, listErr := h.svc.ListBasesByType(c.UserContext(), baseID, expectedBaseType)
	if listErr != nil {
		status, errObj := buildServiceErrorObject("Get failed", listErr, "")
		_ = writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		return true
	}
	if len(rows) > 0 {
		return false
	}

	oppositeRows, oppositeErr := h.svc.ListBasesByType(c.UserContext(), baseID, oppositeType)
	if oppositeErr != nil {
		status, errObj := buildServiceErrorObject("Get failed", oppositeErr, "")
		_ = writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj})
		return true
	}
	if len(oppositeRows) > 0 {
		_ = writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: mismatchDetail,
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}})
		return true
	}

	_ = writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
		Status: "422",
		Title:  "Validation error",
		Detail: "base_id not found",
		Source: &jsonapi.ErrorSource{Pointer: pointer},
	}})
	return true
}

func (h *DutyRosterHandler) resolveBaseTypeByBaseID(ctx context.Context, baseID []byte) (string, error) {
	rows, err := h.svc.ListBasesByType(ctx, baseID, "hems")
	if err != nil {
		return "", err
	}
	if len(rows) > 0 {
		return "hems", nil
	}
	rows, err = h.svc.ListBasesByType(ctx, baseID, "base")
	if err != nil {
		return "", err
	}
	if len(rows) > 0 {
		return "base", nil
	}
	return "", gorm.ErrRecordNotFound
}

func mapDutyRosterMySQLError(err error, fallbackDetail string) (int, string, bool) {
	status := fiber.StatusBadRequest
	detail := strings.TrimSpace(fallbackDetail)

	var mysqlErr *mysqldriver.MySQLError
	if !errors.As(err, &mysqlErr) {
		return status, detail, false
	}

	switch mysqlErr.Number {
	case 1045:
		status = fiber.StatusUnauthorized
		detail = "authentication failed"
	case 1142, 1227:
		status = fiber.StatusForbidden
		detail = "insufficient permission to perform this action"
	case 1205, 1213:
		status = fiber.StatusConflict
		detail = "request conflicted with another transaction; please retry"
	case 2006:
		status = fiber.StatusServiceUnavailable
		detail = "service temporarily unavailable"
	case 1062:
		status = fiber.StatusConflict
		detail = dutyRosterDuplicateDetail(mysqlErr.Message)
	case 1048, 1292, 1366, 1451, 1452, 3819, 4025:
		status = fiber.StatusUnprocessableEntity
		detail = dutyRosterFriendlyMySQLError(mysqlErr.Number)
	default:
		status = fiber.StatusBadRequest
		detail = "invalid request payload"
	}
	return status, detail, true
}

func mapDutyRosterGenericError(detail, title string) (int, string) {
	msg := strings.ToLower(strings.TrimSpace(detail))
	cleanDetail := strings.TrimSpace(detail)
	switch {
	case strings.Contains(msg, "forbidden"), strings.Contains(msg, "permission denied"), strings.Contains(msg, "not allowed"):
		if cleanDetail != "" {
			return fiber.StatusForbidden, cleanDetail
		}
		return fiber.StatusForbidden, "forbidden"
	case strings.Contains(msg, "unauthorized"), strings.Contains(msg, "invalid token"), strings.Contains(msg, "authentication failed"):
		if cleanDetail != "" {
			return fiber.StatusUnauthorized, cleanDetail
		}
		return fiber.StatusUnauthorized, "unauthorized"
	case strings.Contains(msg, "too many requests"), strings.Contains(msg, "rate limit"):
		if cleanDetail != "" {
			return fiber.StatusTooManyRequests, cleanDetail
		}
		return fiber.StatusTooManyRequests, "too many requests"
	case strings.Contains(msg, "conflict"), strings.Contains(msg, "already exists"), strings.Contains(msg, "duplicate"):
		if cleanDetail != "" {
			return fiber.StatusConflict, cleanDetail
		}
		return fiber.StatusConflict, "data conflict"
	case strings.Contains(msg, "internal server error"), strings.Contains(msg, "panic"):
		return fiber.StatusInternalServerError, "internal server error"
	case strings.Contains(msg, "unavailable"), strings.Contains(msg, "connection refused"):
		return fiber.StatusServiceUnavailable, "service temporarily unavailable"
	default:
		_ = title
		if cleanDetail != "" {
			return fiber.StatusBadRequest, cleanDetail
		}
		return fiber.StatusBadRequest, "invalid request payload"
	}
}

var mysqlDuplicateKeyRe = regexp.MustCompile(`(?i)Duplicate entry '.*' for key '([^']+)'`)

func dutyRosterDuplicateDetail(raw string) string {
	key := mysqlDuplicateKey(raw)
	if key == "" {
		return "data conflict: a record with the same unique combination already exists"
	}
	switch key {
	case "idx_roster_base_date":
		return "roster already exists for the selected base and duty date"
	case "idx_roster_crew_unique":
		return "crew assignment already exists for the selected role and shift range"
	default:
		return "data conflict: a record with the same unique combination already exists"
	}
}

func mysqlDuplicateKey(raw string) string {
	msg := strings.TrimSpace(raw)
	if msg == "" {
		return ""
	}
	m := mysqlDuplicateKeyRe.FindStringSubmatch(msg)
	if len(m) < 2 {
		return ""
	}
	key := strings.TrimSpace(m[1])
	if i := strings.LastIndex(key, "."); i >= 0 && i+1 < len(key) {
		key = key[i+1:]
	}
	return strings.Trim(key, "`")
}

func dutyRosterFriendlyMySQLError(code uint16) string {
	switch code {
	case 1048:
		return "required field must not be empty"
	case 1292:
		return "invalid date or time format"
	case 1366:
		return "invalid value for one of the fields"
	case 1451:
		return "cannot delete data because it is still referenced"
	case 1452:
		return "referenced data was not found"
	case 3819, 4025:
		return "data violates one of the validation constraints"
	default:
		return "invalid request payload"
	}
}

func (h *DutyRosterHandler) logCrewAudit(
	c *fiber.Ctx,
	action string,
	rosterID []byte,
	baseType string,
	crews []dutyroster.DutyRosterCrew,
	message string,
) {
	if h.audit == nil || len(crews) == 0 {
		return
	}
	h.audit.Log(c.UserContext(), service.AuditLogInput{
		RequestID:   requestIDFromCtx(c),
		ActorUserID: actorUserID(c),
		Layer:       "handler",
		Module:      "duty_roster",
		Action:      action,
		Method:      c.Method(),
		Path:        c.Path(),
		StatusCode:  fiber.StatusOK,
		Success:     true,
		IP:          c.IP(),
		UserAgent:   c.Get("User-Agent"),
		Message:     message,
		Metadata:    dutyRosterCrewAuditMetadata(rosterID, baseType, crews),
	})
}

func requestIDFromCtx(c *fiber.Ctx) string {
	if v, ok := c.Locals("requestid").(string); ok {
		return strings.TrimSpace(v)
	}
	return strings.TrimSpace(c.Get("X-Request-ID"))
}

func clearCrewShiftDateForCreate(crews []dutyroster.DutyRosterCrew) {
	for i := range crews {
		crews[i].DateStart = nil
		crews[i].DateEnd = nil
	}
}
