package handlers

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

	"github.com/gofiber/fiber/v2"

	"wucher/internal/domain/base"
	filemanager "wucher/internal/domain/file_manager"
	appservice "wucher/internal/service"
	"wucher/internal/shared/pkg/apperrors"
	"wucher/internal/shared/pkg/apperrorsx"
	"wucher/internal/shared/pkg/attachmentresolver"
	"wucher/internal/shared/pkg/userctx"
	"wucher/internal/shared/pkg/uuidv7"
	"wucher/internal/transport/http/dto"
	"wucher/internal/transport/http/jsonapi"
	"wucher/internal/transport/http/response"
	"wucher/internal/transport/http/validators"
)

const (
	basePhotoAttachmentRefType  = "base_photo"
	basePhotoAttachmentCategory = "photo"
)

type basePresignStorage interface {
	PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error)
}

type BaseHandler struct {
	svc         base.Service
	validate    *validators.Validator
	fileManager filemanager.Service
	storage     basePresignStorage
}

type baseDetailedWriter interface {
	CreateDetailed(context.Context, appservice.BaseCreateInput) (*appservice.BaseWriteResult, error)
	UpdateDetailed(context.Context, appservice.BaseUpdateInput) (*appservice.BaseWriteResult, error)
}

func NewBaseHandler(svc base.Service) *BaseHandler {
	return &BaseHandler{svc: svc, validate: validators.New()}
}

func (h *BaseHandler) WithFileManagerService(fileManager filemanager.Service) *BaseHandler {
	h.fileManager = fileManager
	return h
}

func (h *BaseHandler) WithFileStorage(storage basePresignStorage) *BaseHandler {
	h.storage = storage
	return h
}

func (h *BaseHandler) WithFileResolver(fileManager filemanager.Service, storage basePresignStorage) *BaseHandler {
	h.fileManager = fileManager
	h.storage = storage
	return h
}

func (h *BaseHandler) createBaseWithService(c *fiber.Ctx, svc baseDetailedWriter) bool {
	var req dto.BaseCreateRequest
	if err := c.BodyParser(&req); err != nil {
		_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
		return true
	}

	categoryType, errObj := resolveBaseCategoryType(c, req.Data.Attributes.BaseCategory, "/data/attributes/base_category")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	if errObj := validateBaseCoordinates(req.Data.Attributes.Latitude, req.Data.Attributes.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	baseName := strings.TrimSpace(req.Data.Attributes.BaseName)
	if baseName == "" {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "base is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
		}})
		return true
	}
	createdBy, errObj := parseOptionalUUID(req.Data.Attributes.CreatedBy, "/data/attributes/created_by")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	updatedBy, errObj := parseOptionalUUID(req.Data.Attributes.UpdatedBy, "/data/attributes/updated_by")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	defaultStartTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	defaultEndTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	defaultShiftStart, defaultShiftEnd, errObj := parseBaseDefaultShiftRange(
		req.Data.Attributes.DefaultShiftStart,
		req.Data.Attributes.DefaultShiftEnd,
		req.Data.Attributes.DefaultShiftTime,
		defaultStartTimeType,
		defaultEndTimeType,
		true,
		"/data/attributes/default_shift_start",
		"/data/attributes/default_shift_end",
		"/data/attributes/default_shift_time",
	)
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	operationalShiftTimes, errObj := parseBaseOperationalShiftTimes(req.Data.Attributes.OperationalShiftTimes, "/data/attributes/operational_shift_times")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	hemsEDCIDs, errObj := parseUUIDList(req.Data.Attributes.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	medPaxIDs, errObj := parseUUIDList(req.Data.Attributes.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	pilotIDs, errObj := parseUUIDList(req.Data.Attributes.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}

	modelID := uuidv7.MustBytes()
	fotoAttachmentID := []byte(nil)
	if req.Data.Attributes.FileUUID != nil {
		resolved, handled := h.resolveBaseFotoAttachmentID(c, modelID, req.Data.Attributes.FileUUID)
		if handled {
			return true
		}
		fotoAttachmentID = resolved
	}
	res, err := svc.CreateDetailed(c.UserContext(), appservice.BaseCreateInput{
		ID:                                 modelID,
		CategoryType:                       categoryType,
		BaseName:                           baseName,
		BaseAbbreviation:                   stringOrDefault(req.Data.Attributes.BaseAbbreviation, ""),
		Address:                            stringOrDefault(req.Data.Attributes.Address, ""),
		Latitude:                           float64OrDefault(req.Data.Attributes.Latitude, 0),
		Longitude:                          float64OrDefault(req.Data.Attributes.Longitude, 0),
		LandlineNumber:                     stringOrDefault(req.Data.Attributes.LandlineNumber, ""),
		MobileNumber:                       stringOrDefault(req.Data.Attributes.MobileNumber, ""),
		Email:                              stringOrDefault(req.Data.Attributes.Email, ""),
		SortKey:                            req.Data.Attributes.SortKey,
		SMSAlert:                           boolOrDefault(req.Data.Attributes.SMSAlert, false),
		Checklist:                          boolOrDefault(req.Data.Attributes.Checklist, false),
		LegTime:                            stringOrDefault(req.Data.Attributes.LegTime, ""),
		DefaultStartTimeType:               defaultStartTimeType,
		DefaultEndTimeType:                 defaultEndTimeType,
		DefaultShiftStart:                  defaultShiftStart,
		DefaultShiftEnd:                    defaultShiftEnd,
		UTC:                                stringOrDefault(req.Data.Attributes.UTC, ""),
		Dry:                                boolOrDefault(req.Data.Attributes.Dry, false),
		ControlCenter:                      boolOrDefault(req.Data.Attributes.ControlCenter, false),
		DUL:                                boolOrDefault(req.Data.Attributes.DUL, false),
		Notes:                              stringOrDefault(req.Data.Attributes.Notes, ""),
		HEMSEDCContactIDs:          hemsEDCIDs,
		MedPaxContactIDs:           medPaxIDs,
		ResponsiblePilotContactIDs: pilotIDs,
		OperationalShiftTimes:              operationalShiftTimes,
		IsActive:                           boolOrDefault(req.Data.Attributes.IsActive, true),
		CreatedBy:                          createdBy,
		UpdatedBy:                          updatedBy,
		FotoAttachmentID:                   fotoAttachmentID,
	})
	if err != nil {
		var writeErr *appservice.BaseWriteError
		if errors.As(err, &writeErr) {
			errObj := jsonapi.ErrorObject{
				Status: strconv.Itoa(writeErr.Status),
				Title:  writeErr.Title,
				Detail: writeErr.Detail,
			}
			if strings.TrimSpace(writeErr.Pointer) != "" {
				errObj.Source = &jsonapi.ErrorSource{Pointer: writeErr.Pointer}
			}
			_ = writeBaseErrors(c, writeErr.Status, []jsonapi.ErrorObject{{
				Status: errObj.Status, Title: errObj.Title, Detail: errObj.Detail, Source: errObj.Source,
			}})
			return true
		}
		_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Create failed",
			Detail: safeInternalDetail(),
		}})
		return true
	}
	if len(res.PreviousFotoAttachmentID) == 16 {
		_ = syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c))
	}
	_ = response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), res.Row, res.CategoryType))
	return true
}

func (h *BaseHandler) updateBaseWithService(c *fiber.Ctx, svc baseDetailedWriter) bool {
	uuidStr := c.Params("uuid")
	id, err := uuidv7.ParseString(uuidStr)
	if err != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "uuid is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
		}})
		return true
	}

	var req dto.BaseUpdateRequest
	if err := c.BodyParser(&req); err != nil {
		_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
		return true
	}

	attrs := req.Data.Attributes
	categoryType, errObj := resolveBaseCategoryType(c, attrs.BaseCategory, "/data/attributes/base_category")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	if errObj := validateOptionalBaseCoordinates(attrs.Latitude, attrs.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}
	if attrs.BaseName == nil && attrs.BaseAbbreviation == nil && attrs.Address == nil && attrs.Latitude == nil && attrs.Longitude == nil && attrs.LandlineNumber == nil &&
		attrs.MobileNumber == nil && attrs.Email == nil && attrs.SMSAlert == nil && attrs.Checklist == nil &&
		!attrs.SortKey.Set &&
		attrs.LegTime == nil && attrs.DefaultStartTimeType == nil && attrs.DefaultEndTimeType == nil &&
		attrs.DefaultShiftStart == nil && attrs.DefaultShiftEnd == nil && attrs.DefaultShiftTime == nil && attrs.ShiftTime == nil &&
		attrs.UTC == nil && attrs.FileUUID == nil && attrs.Dry == nil &&
		attrs.ControlCenter == nil && attrs.DUL == nil && attrs.Notes == nil && attrs.IsActive == nil &&
		attrs.HEMSEDCContactIDs == nil && attrs.MedPaxContactIDs == nil &&
		attrs.ResponsiblePilotContactIDs == nil &&
		attrs.OperationalShiftTimes == nil &&
		attrs.CreatedBy == nil && attrs.UpdatedBy == nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "at least one attribute must be provided",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
		}})
		return true
	}

	var defaultShiftStart *time.Time
	var defaultShiftEnd *time.Time
	var defaultStartTimeType *string
	var defaultEndTimeType *string
	if attrs.DefaultStartTimeType != nil || attrs.DefaultEndTimeType != nil || attrs.DefaultShiftStart != nil || attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
		startType, errObj := normalizeBaseShiftTimeType(stringOrDefault(attrs.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
		endType, errObj := normalizeBaseShiftTimeType(stringOrDefault(attrs.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
		parsedStart, parsedEnd, errObj := parseBaseDefaultShiftRange(
			attrs.DefaultShiftStart,
			attrs.DefaultShiftEnd,
			attrs.DefaultShiftTime,
			startType,
			endType,
			false,
			"/data/attributes/default_shift_start",
			"/data/attributes/default_shift_end",
			"/data/attributes/default_shift_time",
		)
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
		if attrs.DefaultShiftStart != nil || attrs.DefaultShiftTime != nil {
			defaultShiftStart = parsedStart
		}
		if attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
			defaultShiftEnd = parsedEnd
		}
		if attrs.DefaultStartTimeType != nil {
			defaultStartTimeType = &startType
		}
		if attrs.DefaultEndTimeType != nil {
			defaultEndTimeType = &endType
		}
	}
	if attrs.ShiftTime != nil && (strings.TrimSpace(attrs.ShiftTime.Start) != "" || strings.TrimSpace(attrs.ShiftTime.End) != "") {
		if strings.TrimSpace(attrs.ShiftTime.Start) != "" {
			start, errObj := parseOptionalClock(&attrs.ShiftTime.Start, "/data/attributes/shift_time/start")
			if errObj != nil {
				_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
				return true
			}
			defaultShiftStart = start
		}
		if strings.TrimSpace(attrs.ShiftTime.End) != "" {
			end, errObj := parseOptionalClock(&attrs.ShiftTime.End, "/data/attributes/shift_time/end")
			if errObj != nil {
				_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
				return true
			}
			defaultShiftEnd = end
		}
	}
	operationalShiftTimesSet := attrs.OperationalShiftTimes != nil
	operationalShiftTimes, errObj := parseBaseOperationalShiftTimes(attrs.OperationalShiftTimes, "/data/attributes/operational_shift_times")
	if errObj != nil {
		_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		return true
	}

	var fotoAttachmentID []byte
	fotoAttachmentSet := false
	if attrs.FileUUID != nil {
		fotoAttachmentSet = true
		if hasBlankOptionalString(attrs.FileUUID) {
			fotoAttachmentID = nil
		} else {
			resolved, handled := h.resolveExistingBaseFotoAttachmentOrFileID(c, id, attrs.FileUUID)
			if handled {
				return true
			}
			fotoAttachmentID = resolved
		}
	}

	hemsEDCSet := attrs.HEMSEDCContactIDs != nil
	medPaxSet := attrs.MedPaxContactIDs != nil
	pilotSet := attrs.ResponsiblePilotContactIDs != nil
	var hemsEDCIDs [][]byte
	var medPaxIDs [][]byte
	var pilotIDs [][]byte
	if hemsEDCSet {
		var errObj *jsonapi.ErrorObject
		hemsEDCIDs, errObj = parseUUIDList(*attrs.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
	}
	if medPaxSet {
		var errObj *jsonapi.ErrorObject
		medPaxIDs, errObj = parseUUIDList(*attrs.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
	}
	if pilotSet {
		var errObj *jsonapi.ErrorObject
		pilotIDs, errObj = parseUUIDList(*attrs.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
	}

	var sortKeyValue *int
	sortKeyValid := false
	if attrs.SortKey.Set {
		sortKeyVal := attrs.SortKey.Value
		sortKeyValue = &sortKeyVal
		sortKeyValid = attrs.SortKey.Valid
	}

	createdBySet := attrs.CreatedBy != nil
	updatedBySet := attrs.UpdatedBy != nil
	var createdBy []byte
	var updatedBy []byte
	if createdBySet {
		parsed, errObj := parseUUIDOrClear(*attrs.CreatedBy, "/data/attributes/created_by")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
		createdBy = parsed
	}
	if updatedBySet {
		parsed, errObj := parseUUIDOrClear(*attrs.UpdatedBy, "/data/attributes/updated_by")
		if errObj != nil {
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			return true
		}
		updatedBy = parsed
	}

	res, err := svc.UpdateDetailed(c.UserContext(), appservice.BaseUpdateInput{
		ID:                                    id,
		CategoryType:                          categoryType,
		BaseName:                              attrs.BaseName,
		BaseAbbreviation:                      attrs.BaseAbbreviation,
		Address:                               attrs.Address,
		Latitude:                              attrs.Latitude,
		Longitude:                             attrs.Longitude,
		LandlineNumber:                        attrs.LandlineNumber,
		MobileNumber:                          attrs.MobileNumber,
		Email:                                 attrs.Email,
		SortKeySet:                            attrs.SortKey.Set,
		SortKeyValid:                          sortKeyValid,
		SortKeyValue:                          sortKeyValue,
		SMSAlert:                              attrs.SMSAlert,
		Checklist:                             attrs.Checklist,
		LegTime:                               attrs.LegTime,
		DefaultStartTimeType:                  defaultStartTimeType,
		DefaultEndTimeType:                    defaultEndTimeType,
		DefaultShiftStart:                     defaultShiftStart,
		DefaultShiftEnd:                       defaultShiftEnd,
		UTC:                                   attrs.UTC,
		Dry:                                   attrs.Dry,
		ControlCenter:                         attrs.ControlCenter,
		DUL:                                   attrs.DUL,
		Notes:                                 attrs.Notes,
		HEMSEDCContactIDs:    hemsEDCIDs,
		HEMSEDCContactIDsSet: hemsEDCSet,
		MedPaxContactIDs:     medPaxIDs,
		MedPaxContactIDsSet:  medPaxSet,
		ResponsiblePilotContactIDs:            pilotIDs,
		ResponsiblePilotContactIDsSet:         pilotSet,
		OperationalShiftTimes:                 operationalShiftTimes,
		OperationalShiftTimesSet:              operationalShiftTimesSet,
		IsActive:                              attrs.IsActive,
		CreatedBy:                             createdBy,
		CreatedBySet:                          createdBySet,
		UpdatedBy:                             updatedBy,
		UpdatedBySet:                          updatedBySet,
		FotoAttachmentID:                      fotoAttachmentID,
		FotoAttachmentSet:                     fotoAttachmentSet,
	})
	if err != nil {
		var writeErr *appservice.BaseWriteError
		if errors.As(err, &writeErr) {
			errObj := jsonapi.ErrorObject{
				Status: strconv.Itoa(writeErr.Status),
				Title:  writeErr.Title,
				Detail: writeErr.Detail,
			}
			if strings.TrimSpace(writeErr.Pointer) != "" {
				errObj.Source = &jsonapi.ErrorSource{Pointer: writeErr.Pointer}
			}
			_ = writeBaseErrors(c, writeErr.Status, []jsonapi.ErrorObject{errObj})
			return true
		}
		_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Update failed",
			Detail: safeInternalDetail(),
		}})
		return true
	}
	if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c)); err != nil {
		_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Update failed",
			Detail: safeInternalDetail(),
		}})
		return true
	}
	_ = response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), res.Row, res.CategoryType))
	return true
}

// CreateBase godoc
// @Summary Create base
// @Tags Bases
// @Accept json
// @Produce json
// @Description Example request:
// @Description {"data":{"type":"base","attributes":{"base":"Lude Testing 2","base_abbreviation":"LOIG","base_category":"regular","address":"Lude Airfield","latitude":47.3769,"longitude":8.5417,"sortkey":1,"sms_alert":false,"checklist":false,"leg_time":"00:20","default_start_time_type":"FIXED","default_end_time_type":"FIXED","default_shift_start":"06:00","default_shift_end":"21:00","utc":"0","notes":"Main base","operational_shift_times":[{"date_start":"2026-06-15","date_end":"2026-06-15","start_time_type":"BMCT","end_time_type":"ECET"}]}}}
// @Param request body dto.BaseCreateRequest true "JSON:API base create request"
// @Success 201 {object} dto.BaseResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/bases/create [post]
func (h *BaseHandler) CreateBase(c *fiber.Ctx) error {
	svc, ok := h.svc.(baseDetailedWriter)
	if !ok {
		return writeBaseErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Create failed",
			Detail: "base service is not configured",
		}})
	}
	if h.createBaseWithService(c, svc) {
		return nil
	}

	var req dto.BaseCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}
	categoryType, errObj := resolveBaseCategoryType(c, req.Data.Attributes.BaseCategory, "/data/attributes/base_category")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	if errObj := validateBaseCoordinates(req.Data.Attributes.Latitude, req.Data.Attributes.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	baseName := strings.TrimSpace(req.Data.Attributes.BaseName)
	if baseName == "" {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "base is required",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
		}})
	}

	createdBy, errObj := parseOptionalUUID(req.Data.Attributes.CreatedBy, "/data/attributes/created_by")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	updatedBy, errObj := parseOptionalUUID(req.Data.Attributes.UpdatedBy, "/data/attributes/updated_by")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	defaultStartTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	defaultEndTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	defaultShiftStart, defaultShiftEnd, errObj := parseBaseDefaultShiftRange(
		req.Data.Attributes.DefaultShiftStart,
		req.Data.Attributes.DefaultShiftEnd,
		req.Data.Attributes.DefaultShiftTime,
		defaultStartTimeType,
		defaultEndTimeType,
		true,
		"/data/attributes/default_shift_start",
		"/data/attributes/default_shift_end",
		"/data/attributes/default_shift_time",
	)
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	row := &base.Base{ID: uuidv7.MustBytes()}
	*row = *dto.MapBaseCreateAttributesToDomain(req.Data.Attributes)
	row.ID = uuidv7.MustBytes()
	row.BaseName = baseName
	row.DefaultStartTimeType = defaultStartTimeType
	row.DefaultEndTimeType = defaultEndTimeType
	row.DefaultShiftStart = clockStringOrEmpty(defaultShiftStart)
	row.DefaultShiftEnd = clockStringOrEmpty(defaultShiftEnd)
	if req.Data.Attributes.FileUUID != nil {
		fotoAttachmentID, resolveErr := h.resolveBaseFotoAttachmentID(c, row.ID, req.Data.Attributes.FileUUID)
		if resolveErr {
			return nil
		}
		row.FotoAttachmentID = fotoAttachmentID
	}
	hemsEDCIDs, errObj := parseUUIDList(req.Data.Attributes.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	medPaxIDs, errObj := parseUUIDList(req.Data.Attributes.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	pilotIDs, errObj := parseUUIDList(req.Data.Attributes.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	row.HEMSEDCContactIDs = hemsEDCIDs
	row.MedPaxContactIDs = medPaxIDs
	row.ResponsiblePilotContactIDs = pilotIDs
	row.CreatedBy = createdBy
	row.UpdatedBy = updatedBy

	if err := h.svc.CreateBase(c.UserContext(), row, categoryType); err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Create failed",
			Detail: safeInternalDetail(),
		}})
	}
	if len(row.FotoAttachmentID) == 16 {
		if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, nil, row.FotoAttachmentID, actorUserID(c)); err != nil {
			return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "Create failed",
				Detail: safeInternalDetail(),
			}})
		}
	}

	created, err := h.svc.GetBaseByID(c.UserContext(), row.ID, categoryType)
	if err == nil && created != nil {
		return response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), created, categoryType))
	}
	return response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), row, categoryType))
}

// UpdateBase godoc
// @Summary Update base
// @Tags Bases
// @Accept json
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Description Example request:
// @Description {"data":{"type":"hems_base","id":"<uuid>","attributes":{"base":"Lude Testing 2","base_abbreviation":"LOIG","base_category":"regular","address":"Lude Airfield","latitude":47.3769,"longitude":8.5417,"sortkey":1,"sms_alert":false,"checklist":false,"leg_time":"00:20","default_start_time_type":"FIXED","default_end_time_type":"FIXED","default_shift_start":"06:00","default_shift_end":"21:00","utc":"0","notes":"Main base","operational_shift_times":[{"date_start":"2026-06-15","date_end":"2026-06-15","start_time_type":"BMCT","end_time_type":"ECET"}]}}}
// @Param request body dto.BaseUpdateRequest true "JSON:API base update request"
// @Success 200 {object} dto.BaseResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/update/{uuid} [patch]
func (h *BaseHandler) UpdateBase(c *fiber.Ctx) error {
	svc, ok := h.svc.(baseDetailedWriter)
	if !ok {
		return writeBaseErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
			Status: "500",
			Title:  "Update failed",
			Detail: "base service is not configured",
		}})
	}
	if h.updateBaseWithService(c, svc) {
		return nil
	}

	uuidStr := c.Params("uuid")
	id, err := uuidv7.ParseString(uuidStr)
	if err != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "uuid is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
		}})
	}

	var req dto.BaseUpdateRequest
	rawBody := append([]byte(nil), c.Body()...)
	if err := c.BodyParser(&req); err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Invalid JSON",
			Detail: safeInternalDetail(),
		}})
	}

	attrs := req.Data.Attributes
	if attrs.ShiftTime == nil {
		attrs.ShiftTime = extractBaseShiftTimeFromUpdateBody(rawBody)
	}
	categoryType, errObj := resolveBaseCategoryType(c, attrs.BaseCategory, "/data/attributes/base_category")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	if errObj := validateOptionalBaseCoordinates(attrs.Latitude, attrs.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	if attrs.BaseName == nil && attrs.BaseAbbreviation == nil && attrs.Address == nil && attrs.LandlineNumber == nil &&
		attrs.MobileNumber == nil && attrs.Email == nil && attrs.SMSAlert == nil && attrs.Checklist == nil &&
		!attrs.SortKey.Set &&
		attrs.LegTime == nil && attrs.DefaultStartTimeType == nil && attrs.DefaultEndTimeType == nil &&
		attrs.DefaultShiftStart == nil && attrs.DefaultShiftEnd == nil && attrs.DefaultShiftTime == nil && attrs.ShiftTime == nil &&
		attrs.UTC == nil && attrs.FileUUID == nil && attrs.Dry == nil &&
		attrs.ControlCenter == nil && attrs.DUL == nil && attrs.Notes == nil && attrs.IsActive == nil &&
		attrs.HEMSEDCContactIDs == nil && attrs.MedPaxContactIDs == nil &&
		attrs.ResponsiblePilotContactIDs == nil &&
		attrs.CreatedBy == nil && attrs.UpdatedBy == nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "at least one attribute must be provided",
			Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
		}})
	}

	existing, err := h.svc.GetBaseByID(c.UserContext(), id, categoryType)
	if err != nil || existing == nil {
		return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: categoryNotFoundDetail(categoryType),
		}})
	}
	prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...)

	if attrs.BaseName != nil {
		v := strings.TrimSpace(*attrs.BaseName)
		if v == "" {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: "base cannot be empty",
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
			}})
		}
		existing.BaseName = v
	}
	dto.ApplyBaseUpdateAttributesToDomain(existing, attrs)
	if attrs.DefaultStartTimeType != nil {
		parsed, errObj := normalizeBaseShiftTimeType(*attrs.DefaultStartTimeType, "/data/attributes/default_start_time_type")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.DefaultStartTimeType = parsed
	}
	if attrs.DefaultEndTimeType != nil {
		parsed, errObj := normalizeBaseShiftTimeType(*attrs.DefaultEndTimeType, "/data/attributes/default_end_time_type")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.DefaultEndTimeType = parsed
	}
	if attrs.DefaultShiftStart != nil || attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
		parsedStart, parsedEnd, errObj := parseBaseDefaultShiftRange(
			attrs.DefaultShiftStart,
			attrs.DefaultShiftEnd,
			attrs.DefaultShiftTime,
			normalizeBaseShiftTimeTypeOrFixed(existing.DefaultStartTimeType),
			normalizeBaseShiftTimeTypeOrFixed(existing.DefaultEndTimeType),
			false,
			"/data/attributes/default_shift_start",
			"/data/attributes/default_shift_end",
			"/data/attributes/default_shift_time",
		)
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		if attrs.DefaultShiftStart != nil || attrs.DefaultShiftTime != nil {
			existing.DefaultShiftStart = clockStringOrEmpty(parsedStart)
		}
		if attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
			existing.DefaultShiftEnd = clockStringOrEmpty(parsedEnd)
		}
	}
	if normalizeBaseShiftTimeTypeOrFixed(existing.DefaultStartTimeType) != base.ShiftTimeTypeFixed {
		existing.DefaultShiftStart = ""
	}
	if normalizeBaseShiftTimeTypeOrFixed(existing.DefaultEndTimeType) != base.ShiftTimeTypeFixed {
		existing.DefaultShiftEnd = ""
	}
	if attrs.FileUUID != nil {
		if hasBlankOptionalString(attrs.FileUUID) {
			existing.FotoAttachmentID = nil
		} else {
			fotoAttachmentID, resolveErr := h.resolveExistingBaseFotoAttachmentOrFileID(c, existing.ID, attrs.FileUUID)
			if resolveErr {
				return nil
			}
			existing.FotoAttachmentID = fotoAttachmentID
		}
	}
	if attrs.HEMSEDCContactIDs != nil {
		parsed, errObj := parseUUIDList(*attrs.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.HEMSEDCContactIDs = parsed
	}
	if attrs.MedPaxContactIDs != nil {
		parsed, errObj := parseUUIDList(*attrs.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.MedPaxContactIDs = parsed
	}
	if attrs.ResponsiblePilotContactIDs != nil {
		parsed, errObj := parseUUIDList(*attrs.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.ResponsiblePilotContactIDs = parsed
	}
	if attrs.CreatedBy != nil {
		parsed, errObj := parseUUIDOrClear(*attrs.CreatedBy, "/data/attributes/created_by")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.CreatedBy = parsed
	}
	if attrs.UpdatedBy != nil {
		parsed, errObj := parseUUIDOrClear(*attrs.UpdatedBy, "/data/attributes/updated_by")
		if errObj != nil {
			return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
		}
		existing.UpdatedBy = parsed
	}

	// Convert shift_time object to individual fields (handler responsibility for HTTP format translation)
	if attrs.ShiftTime != nil && (attrs.ShiftTime.Start != "" || attrs.ShiftTime.End != "") {
		if attrs.ShiftTime.Start != "" {
			start, errObj := parseOptionalClock(&attrs.ShiftTime.Start, "/data/attributes/shift_time/start")
			if errObj != nil {
				return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			}
			existing.DefaultShiftStart = clockStringOrEmpty(start)
		}
		if attrs.ShiftTime.End != "" {
			end, errObj := parseOptionalClock(&attrs.ShiftTime.End, "/data/attributes/shift_time/end")
			if errObj != nil {
				return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
			}
			existing.DefaultShiftEnd = clockStringOrEmpty(end)
		}
	}

	if err := h.svc.UpdateBase(c.UserContext(), existing, categoryType); err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Update failed",
			Detail: safeInternalDetail(),
		}})
	}
	if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, prevFotoAttachmentID, existing.FotoAttachmentID, actorUserID(c)); err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "Update failed",
			Detail: safeInternalDetail(),
		}})
	}

	updated, err := h.svc.GetBaseByID(c.UserContext(), existing.ID, categoryType)
	if err == nil && updated != nil {
		if attrs.ShiftTime != nil && (attrs.ShiftTime.Start != "" || attrs.ShiftTime.End != "") {
			updated.DefaultShiftStart = existing.DefaultShiftStart
			updated.DefaultShiftEnd = existing.DefaultShiftEnd
		}
		if attrs.OperationalShiftTimes != nil {
			updated.OperationalShiftTimes = existing.OperationalShiftTimes
		}
		return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), updated, categoryType))
	}
	return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), existing, categoryType))
}

// DeleteBase godoc
// @Summary Delete base
// @Tags Bases
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Success 200 {object} dto.BaseDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/delete/{uuid} [delete]
func (h *BaseHandler) DeleteBase(c *fiber.Ctx) error {
	categoryType, errObj := parseBaseCategoryTypeQuery(c)
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	uuidStr := c.Params("uuid")
	id, err := uuidv7.ParseString(uuidStr)
	if err != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "uuid is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
		}})
	}

	row, err := h.svc.DeleteBase(c.UserContext(), id, categoryType)
	if err != nil {
		if _, ok := apperrors.AsDeleteConflict(err); ok {
			def := apperrorsx.ErrBaseRelationDelete
			return jsonapi.WriteErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
				Status:    "409",
				Code:      def.Code,
				ErrorCode: def.ErrorCode,
				Title:     def.Title,
				Detail:    err.Error(),
			}})
		}
		return writeDeleteError(c, err)
	}
	if row == nil {
		return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: "base not found",
		}})
	}
	if row == nil {
		return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: categoryNotFoundDetail(categoryType),
		}})
	}
	if row != nil && len(row.FotoAttachmentID) == 16 {
		if err := syncAttachmentLifecycleClear(c.UserContext(), h.fileManager, row.FotoAttachmentID, actorUserID(c)); err != nil {
			return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  "Delete failed",
				Detail: safeInternalDetail(),
			}})
		}
	}

	deleteType := "base_delete"
	if categoryType == base.CategoryKeyHEMS {
		deleteType = "hems_base_delete"
	}

	idStr, _ := uuidv7.BytesToString(id)
	if row == nil {
		// fallback minimal response when no row is available
		return response.Write(c, fiber.StatusOK, jsonapi.Resource{
			Type:       deleteType,
			ID:         idStr,
			Attributes: map[string]any{"deleted": true},
		})
	}

	// minimal attributes required for delete response per request
	attrs := map[string]any{
		"base":              row.BaseName,
		"base_abbreviation": row.BaseAbbreviation,
		"base_category":     baseCategoryFromRowOrType(row, categoryType),
		"created_at":        row.CreatedAt.UTC().Format(time.RFC3339),
		"created_by":        bytesUUIDToString(row.CreatedBy),
		"updated_at":        row.UpdatedAt.UTC().Format(time.RFC3339),
		"updated_by":        bytesUUIDToString(row.UpdatedBy),
		"deleted_at":        timeStringOrEmpty(row.DeletedAt),
		"deleted_by":        bytesUUIDToString(row.DeletedBy),
	}

	return response.Write(c, fiber.StatusOK, jsonapi.Resource{
		Type:       deleteType,
		ID:         idStr,
		Attributes: attrs,
	})
}

// GetBaseByID godoc
// @Summary Get base by ID
// @Tags Bases
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Success 200 {object} dto.BaseResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/get/{uuid} [get]
func (h *BaseHandler) GetBase(c *fiber.Ctx) error {
	categoryType, errObj := parseBaseCategoryTypeQuery(c)
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	uuidStr := c.Params("uuid")
	id, err := uuidv7.ParseString(uuidStr)
	if err != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
			Status: "422",
			Title:  "Validation error",
			Detail: "uuid is invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
		}})
	}

	row, err := h.svc.GetBaseByID(c.UserContext(), id, categoryType)
	if err != nil || row == nil {
		return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
			Status: "404",
			Title:  "Not found",
			Detail: categoryNotFoundDetail(categoryType),
		}})
	}

	return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), row, categoryType))
}

// ListBases godoc
// @Summary List bases
// @Description JSON:API list with filtering and sorting. This endpoint returns all bases.
// @Tags Bases
// @Produce json
// @Param category_type query string false "Base category" Enums(regular,hems)
// @Param base_category query string false "Alias filter for base category" Enums(regular,hems)
// @Param search query string false "Search by base/base_abbreviation/address/email/phone"
// @Param dul query boolean false "Filter by DUL flag (true/false)"
// @Param page query int false "Ignored"
// @Param size query int false "Ignored"
// @Param limit query int false "Ignored"
// @Param sort query string false "Sort order"
// @Success 200 {object} dto.BaseListResponse
// @Router /api/v1/bases/get-all [get]
func (h *BaseHandler) ListBases(c *fiber.Ctx) error {
	categoryType, errObj := parseOptionalBaseCategoryTypeQuery(c)
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	dulFilter, errObj := parseOptionalBoolQueryParam(c, "dul")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	filter := c.Query("search")
	sort := c.Query("sort")

	rows, total, err := h.svc.ListBases(c.UserContext(), filter, sort, 0, 0, categoryType, dulFilter)
	if err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "List failed",
			Detail: safeInternalDetail(),
		}})
	}

	data := make([]dto.BaseResource, 0, len(rows))
	for i := range rows {
		data = append(data, h.baseResourceByCategory(c.UserContext(), &rows[i], categoryType))
	}

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

// ListBasesDatatable godoc
// @Summary List bases (datatable)
// @Tags Bases
// @Produce json
// @Param category_type query string false "Base category" Enums(regular,hems)
// @Param base_category query string false "Alias filter for base category" Enums(regular,hems)
// @Param page query int false "Page number (default 1)"
// @Param size query int false "Page size (default 20, max 100)"
// @Param limit query int false "Page size override (same as size)"
// @Param draw query int false "Draw counter (optional)"
// @Param search query string false "Search term"
// @Param dul query boolean false "Filter by DUL flag (true/false)"
// @Success 200 {object} dto.BaseDataTableResponse
// @Router /api/v1/bases/get-all/dt [get]
func (h *BaseHandler) ListBasesDatatable(c *fiber.Ctx) error {
	categoryType, errObj := parseOptionalBaseCategoryTypeQuery(c)
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}
	dulFilter, errObj := parseOptionalBoolQueryParam(c, "dul")
	if errObj != nil {
		return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
	}

	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)
	search := c.Query("search")

	rows, total, err := h.svc.ListBases(c.UserContext(), search, "", length, start, categoryType, dulFilter)
	if err != nil {
		return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
			Status: "400",
			Title:  "List failed",
			Detail: safeInternalDetail(),
		}})
	}

	data := make([]dto.BaseResource, 0, len(rows))
	for i := range rows {
		data = append(data, h.baseResourceByCategory(c.UserContext(), &rows[i], categoryType))
	}

	meta := map[string]any{
		"draw":             draw,
		"records_total":    total,
		"records_filtered": total,
	}
	return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
}

func (h *BaseHandler) baseResource(ctx context.Context, row *base.Base, categoryType string) dto.BaseResource {
	baseRow := row
	id := ""
	defaultShiftStart := ""
	defaultShiftEnd := ""
	defaultStartTimeType := base.ShiftTimeTypeFixed
	defaultEndTimeType := base.ShiftTimeTypeFixed
	if baseRow != nil {
		id, _ = uuidv7.BytesToString(baseRow.ID)
		defaultStartTimeType = normalizeBaseShiftTimeTypeOrFixed(baseRow.DefaultStartTimeType)
		defaultEndTimeType = normalizeBaseShiftTimeTypeOrFixed(baseRow.DefaultEndTimeType)
		defaultShiftStart = dto.DisplayBaseShiftValue(defaultStartTimeType, baseRow.DefaultShiftStart)
		defaultShiftEnd = dto.DisplayBaseShiftValue(defaultEndTimeType, baseRow.DefaultShiftEnd)
	}
	baseCategory := baseCategoryFromRowOrType(baseRow, categoryType)
	resourceType := "base"
	if baseCategory == base.CategoryKeyHEMS {
		resourceType = "hems_base"
	}
	var foto *dto.BaseFoto
	fotoUUID := ""
	if baseRow != nil {
		fotoUUID = idString(baseRow.FotoAttachmentID)
	}
	fotoDownloadURL := ""
	fotoThumbnailURL := ""
	var fotoOriginalSizeBytes *int64
	if baseRow != nil && baseRow.FotoAttachment != nil && baseRow.FotoAttachment.File != nil {
		asset := resolveFileManagerDownloadURLs(ctx, h.storage, baseRow.FotoAttachment.File)
		fotoDownloadURL = strings.TrimSpace(asset.OriginalURL)
		fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL)
		fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes)
	}
	if fotoUUID != "" || fotoDownloadURL != "" || fotoThumbnailURL != "" || fotoOriginalSizeBytes != nil {
		foto = &dto.BaseFoto{
			UUID:                 fotoUUID,
			DownloadURL:          fotoDownloadURL,
			ThumbnailDownloadURL: fotoThumbnailURL,
			OriginalSizeBytes:    fotoOriginalSizeBytes,
		}
	}
	var hemsEDCIDs []string
	var hemsEDCs []dto.BaseContact
	var medPaxIDs []string
	var medPax []dto.BaseContact
	var pilotIDs []string
	var pilots []dto.BaseContact
	if baseRow != nil {
		hemsEDCIDs = uuidListToStrings(baseRow.HEMSEDCContactIDs)
		hemsEDCs = baseContactResources(baseRow.HEMSEDCs)
		medPaxIDs = uuidListToStrings(baseRow.MedPaxContactIDs)
		medPax = baseContactResources(baseRow.MedPax)
		pilotIDs = uuidListToStrings(baseRow.ResponsiblePilotContactIDs)
		pilots = baseContactResources(baseRow.ResponsiblePilots)
	}
	operationalShiftTimes := []dto.BaseOperationalShiftTime(nil)
	if baseRow != nil {
		operationalShiftTimes = dto.MapBaseOperationalShiftTimesToDTO(baseRow.OperationalShiftTimes)
	}

	return dto.MapBaseToResource(baseRow, dto.BaseResponseMappingInput{
		ResourceType:                       resourceType,
		BaseCategory:                       baseCategory,
		ID:                                 id,
		DefaultStartTimeType:               defaultStartTimeType,
		DefaultEndTimeType:                 defaultEndTimeType,
		ShiftTime:                          dto.BaseShiftTime{Start: defaultShiftStart, End: defaultShiftEnd},
		Foto:                               foto,
		Latitude:                           float64FromBase(baseRow),
		Longitude:                          float64FromBaseLongitude(baseRow),
		HEMSEDCContactIDs:  hemsEDCIDs,
		HEMSEDCs:           hemsEDCs,
		MedPaxContactIDs:   medPaxIDs,
		MedPax:             medPax,
		ResponsiblePilotContactIDs: pilotIDs,
		ResponsiblePilots:          pilots,
		OperationalShiftTimes:              operationalShiftTimes,
	})
}

func float64FromBase(row *base.Base) float64 {
	if row == nil {
		return 0
	}
	return row.Latitude
}

func float64FromBaseLongitude(row *base.Base) float64 {
	if row == nil {
		return 0
	}
	return row.Longitude
}

func cloneIntPointerBase(v *int) *int {
	if v == nil {
		return nil
	}
	copied := *v
	return &copied
}

func preferStringPtr(primary, fallback *string) *string {
	if primary != nil {
		return primary
	}
	return fallback
}

func parseUUIDList(values []string, pointer string) ([][]byte, *jsonapi.ErrorObject) {
	out := make([][]byte, 0, len(values))
	seen := map[string]struct{}{}
	for i := range values {
		v := strings.TrimSpace(values[i])
		if v == "" {
			continue
		}
		id, err := uuidv7.ParseString(v)
		if err != nil {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "value is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: pointer},
			}
		}
		key := string(id)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, id)
	}
	return out, nil
}

func uuidListToStrings(values [][]byte) []string {
	if len(values) == 0 {
		return nil
	}
	out := make([]string, 0, len(values))
	for i := range values {
		id, err := uuidv7.BytesToString(values[i])
		if err != nil {
			continue
		}
		out = append(out, id)
	}
	return out
}

func baseContactResources(values []base.BaseContactPerson) []dto.BaseContact {
	if len(values) == 0 {
		return nil
	}
	out := make([]dto.BaseContact, 0, len(values))
	for i := range values {
		id, err := uuidv7.BytesToString(values[i].ContactID)
		if err != nil || strings.TrimSpace(id) == "" {
			continue
		}
		out = append(out, dto.BaseContact{
			ID:        id,
			FirstName: values[i].FirstName,
			LastName:  values[i].LastName,
		})
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

func (h *BaseHandler) baseResourceByCategory(ctx context.Context, row *base.Base, categoryType string) dto.BaseResource {
	return h.baseResource(ctx, row, categoryType)
}

func (h *BaseHandler) resolveBaseFotoAttachmentID(
	c *fiber.Ctx,
	baseID []byte,
	rawFileUUID *string,
) ([]byte, bool) {
	return h.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
		FileManager: h.fileManager,

		RawFileID: rawFileUUID,

		FileField: "file_uuid",

		RequireValue: false,

		RefType:  basePhotoAttachmentRefType,
		RefID:    idString(baseID),
		Category: ptrString(basePhotoAttachmentCategory),

		IsPrimary: true,
		ActorID:   actorUserID(c),

		ValidateAttachmentExists: false,
	}, "Update failed")
}

func (h *BaseHandler) resolveExistingBaseFotoAttachmentOrFileID(
	c *fiber.Ctx,
	baseID []byte,
	rawUUID *string,
) ([]byte, bool) {
	if hasNonEmptyOptionalString(rawUUID) && h.fileManager != nil {
		candidateID, err := uuidv7.ParseString(strings.TrimSpace(*rawUUID))
		if err == nil {
			attachment, getErr := h.fileManager.GetAttachmentByID(c.UserContext(), candidateID)
			if getErr != nil {
				_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
					Status: "400",
					Title:  "Update failed",
					Detail: getErr.Error(),
				}})
				return nil, true
			}
			if attachment != nil {
				return candidateID, false
			}
		}
	}
	return h.resolveBaseFotoAttachmentID(c, baseID, rawUUID)
}

func (h *BaseHandler) resolveAttachmentIDFromInput(
	c *fiber.Ctx,
	params attachmentresolver.Params,
	errorTitle string,
) ([]byte, bool) {
	attachmentID, err := attachmentresolver.Resolve(c.UserContext(), params)
	if err == nil {
		return attachmentID, false
	}

	var resolveErr *attachmentresolver.ResolveError
	if errors.As(err, &resolveErr) {
		switch resolveErr.Code {
		case attachmentresolver.ErrorInvalidAttachmentUUID:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")},
			}})
			return nil, true
		case attachmentresolver.ErrorInvalidFileUUID:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.FileField, "file_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
			}})
			return nil, true
		case attachmentresolver.ErrorAttachmentNotFound:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("%s not found", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")},
			}})
			return nil, true
		case attachmentresolver.ErrorRequireValue:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("%s or %s is required", fieldOrDefault(resolveErr.AttachmentField, "attachment_id"), fieldOrDefault(resolveErr.FileField, "file_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
			}})
			return nil, true
		case attachmentresolver.ErrorCannotResolveFromFile:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("cannot resolve attachment from %s", fieldOrDefault(resolveErr.FileField, "file_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
			}})
			return nil, true
		case attachmentresolver.ErrorFileNotReadyForAttachment:
			_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
				Status: "422",
				Title:  "Validation error",
				Detail: fmt.Sprintf("%s is not ready yet; wait for file.updated SSE event", fieldOrDefault(resolveErr.FileField, "file_id")),
				Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
			}})
			return nil, true
		case attachmentresolver.ErrorFileManagerUnavailable:
			_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
				Status: "400",
				Title:  errorTitle,
				Detail: resolveErr.Error(),
			}})
			return nil, true
		}
	}

	_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
		Status: "400",
		Title:  errorTitle,
		Detail: safeInternalDetail(),
	}})
	return nil, true
}

func parseBaseShiftRange(
	startRaw *string,
	endRaw *string,
	legacyRaw *string,
	startPointer string,
	endPointer string,
	legacyPointer string,
) (*time.Time, *time.Time, *jsonapi.ErrorObject) {
	start, errObj := parseOptionalClock(startRaw, startPointer)
	if errObj != nil {
		return nil, nil, errObj
	}
	end, errObj := parseOptionalClock(endRaw, endPointer)
	if errObj != nil {
		return nil, nil, errObj
	}
	if legacyRaw == nil {
		return start, end, nil
	}

	legacy := strings.TrimSpace(*legacyRaw)
	if legacy == "" {
		return start, end, nil
	}
	legacyStartRaw, legacyEndRaw := extractBaseShiftRangeTokens(legacy)
	if legacyStartRaw == "" && legacyEndRaw == "" {
		return nil, nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "default_shift_time must contain HH:MM or HH:MM:SS range",
			Source: &jsonapi.ErrorSource{Pointer: legacyPointer},
		}
	}
	if startRaw == nil && legacyStartRaw != "" {
		start, errObj = parseOptionalClock(&legacyStartRaw, legacyPointer)
		if errObj != nil {
			return nil, nil, errObj
		}
	}
	if endRaw == nil && legacyEndRaw != "" {
		end, errObj = parseOptionalClock(&legacyEndRaw, legacyPointer)
		if errObj != nil {
			return nil, nil, errObj
		}
	}
	return start, end, nil
}

func parseBaseDefaultShiftRange(
	startRaw *string,
	endRaw *string,
	legacyRaw *string,
	startTimeType string,
	endTimeType string,
	requireFixedTimes bool,
	startPointer string,
	endPointer string,
	legacyPointer string,
) (*time.Time, *time.Time, *jsonapi.ErrorObject) {
	start, end, errObj := parseBaseShiftRange(startRaw, endRaw, legacyRaw, startPointer, endPointer, legacyPointer)
	if errObj != nil {
		return nil, nil, errObj
	}
	startTimeType = normalizeBaseShiftTimeTypeOrFixed(startTimeType)
	endTimeType = normalizeBaseShiftTimeTypeOrFixed(endTimeType)
	if requireFixedTimes && startTimeType == base.ShiftTimeTypeFixed && start == nil {
		return nil, nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "default_shift_start is required when default_start_time_type is FIXED",
			Source: &jsonapi.ErrorSource{Pointer: startPointer},
		}
	}
	if requireFixedTimes && endTimeType == base.ShiftTimeTypeFixed && end == nil {
		return nil, nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "default_shift_end is required when default_end_time_type is FIXED",
			Source: &jsonapi.ErrorSource{Pointer: endPointer},
		}
	}
	if start != nil && end != nil && start.After(*end) {
		return nil, nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "default_shift_start must be earlier than default_shift_end",
			Source: &jsonapi.ErrorSource{Pointer: legacyPointer},
		}
	}
	if startTimeType != base.ShiftTimeTypeFixed {
		start = nil
	}
	if endTimeType != base.ShiftTimeTypeFixed {
		end = nil
	}
	return start, end, nil
}

func parseBaseOperationalShiftTimes(items []dto.BaseOperationalShiftTime, basePointer string) ([]appservice.BaseOperationalShiftTimeInput, *jsonapi.ErrorObject) {
	if len(items) == 0 {
		return nil, nil
	}
	out := make([]appservice.BaseOperationalShiftTimeInput, 0, len(items))
	for i := range items {
		idxPointer := fmt.Sprintf("%s/%d", basePointer, i)
		startRaw := strings.TrimSpace(items[i].DateStart)
		endRaw := strings.TrimSpace(items[i].DateEnd)
		if startRaw == "" || endRaw == "" {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "date_start and date_end are required",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_start"},
			}
		}
		dateStart, err := time.Parse("2006-01-02", startRaw)
		if err != nil {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "date_start must be YYYY-MM-DD",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_start"},
			}
		}
		dateEnd, err := time.Parse("2006-01-02", endRaw)
		if err != nil {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "date_end must be YYYY-MM-DD",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_end"},
			}
		}
		if dateEnd.Before(dateStart) {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "date_end must be >= date_start",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_end"},
			}
		}
		shiftStartRaw := strings.TrimSpace(items[i].ShiftTime.Start)
		shiftEndRaw := strings.TrimSpace(items[i].ShiftTime.End)
		startTimeType, errObj := normalizeBaseShiftTimeType(items[i].StartTimeType, idxPointer+"/start_time_type")
		if errObj != nil {
			return nil, errObj
		}
		endTimeType, errObj := normalizeBaseShiftTimeType(items[i].EndTimeType, idxPointer+"/end_time_type")
		if errObj != nil {
			return nil, errObj
		}
		if startTimeType == base.ShiftTimeTypeFixed && shiftStartRaw == "" {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "shift_time.start is required when start_time_type is FIXED",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time/start"},
			}
		}
		if endTimeType == base.ShiftTimeTypeFixed && shiftEndRaw == "" {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "shift_time.end is required when end_time_type is FIXED",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time/end"},
			}
		}
		var start *time.Time
		var end *time.Time
		var clockErr *jsonapi.ErrorObject
		if shiftStartRaw != "" {
			start, clockErr = parseOptionalClock(&shiftStartRaw, idxPointer+"/shift_time/start")
			if clockErr != nil {
				return nil, clockErr
			}
		}
		if shiftEndRaw != "" {
			end, clockErr = parseOptionalClock(&shiftEndRaw, idxPointer+"/shift_time/end")
			if clockErr != nil {
				return nil, clockErr
			}
		}
		if start != nil && end != nil && start.After(*end) {
			return nil, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "shift_time.start must be earlier than shift_time.end",
				Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time"},
			}
		}
		out = append(out, appservice.BaseOperationalShiftTimeInput{
			DateStart:     dateStart.UTC(),
			DateEnd:       dateEnd.UTC(),
			StartTimeType: startTimeType,
			EndTimeType:   endTimeType,
			ShiftStart:    start,
			ShiftEnd:      end,
		})
	}
	return out, nil
}

func validateBaseCoordinates(lat, lon *float64, latPointer, lonPointer string) *jsonapi.ErrorObject {
	if lat == nil || lon == nil {
		return &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "latitude and longitude are required",
			Source: &jsonapi.ErrorSource{Pointer: latPointer},
		}
	}
	if errObj := validateCoordinateRange(*lat, -90, 90, "latitude", latPointer); errObj != nil {
		return errObj
	}
	if errObj := validateCoordinateRange(*lon, -180, 180, "longitude", lonPointer); errObj != nil {
		return errObj
	}
	return nil
}

func validateOptionalBaseCoordinates(lat, lon *float64, latPointer, lonPointer string) *jsonapi.ErrorObject {
	if lat == nil && lon == nil {
		return nil
	}
	if lat == nil || lon == nil {
		pointer := latPointer
		if lat == nil {
			pointer = latPointer
		} else {
			pointer = lonPointer
		}
		return &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "latitude and longitude must be provided together",
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
	if errObj := validateCoordinateRange(*lat, -90, 90, "latitude", latPointer); errObj != nil {
		return errObj
	}
	if errObj := validateCoordinateRange(*lon, -180, 180, "longitude", lonPointer); errObj != nil {
		return errObj
	}
	return nil
}

func validateCoordinateRange(value, min, max float64, label, pointer string) *jsonapi.ErrorObject {
	if math.IsNaN(value) || value < min || value > max {
		return &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: fmt.Sprintf("%s must be between %.0f and %.0f", label, min, max),
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
	return nil
}

func extractBaseShiftTimeFromUpdateBody(body []byte) *dto.BaseShiftTime {
	var payload struct {
		Data struct {
			Attributes struct {
				ShiftTime *dto.BaseShiftTime `json:"shift_time"`
			} `json:"attributes"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &payload); err != nil {
		return nil
	}
	return payload.Data.Attributes.ShiftTime
}

func parseOptionalClock(v *string, pointer string) (*time.Time, *jsonapi.ErrorObject) {
	if v == nil {
		return nil, nil
	}
	s := strings.TrimSpace(*v)
	if s == "" {
		return nil, nil
	}
	if len(s) == 5 {
		s += ":00"
	}
	parsed, err := time.Parse("15:04:05", s)
	if err != nil {
		return nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "time must be HH:MM or HH:MM:SS",
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
	val := time.Date(2000, 1, 1, parsed.Hour(), parsed.Minute(), parsed.Second(), 0, time.UTC)
	return &val, nil
}

func normalizeBaseShiftTimeType(raw string, pointer string) (string, *jsonapi.ErrorObject) {
	switch strings.ToUpper(strings.TrimSpace(raw)) {
	case "", base.ShiftTimeTypeFixed:
		return base.ShiftTimeTypeFixed, nil
	case base.ShiftTimeTypeBMCT:
		return base.ShiftTimeTypeBMCT, nil
	case base.ShiftTimeTypeECET:
		return base.ShiftTimeTypeECET, nil
	default:
		return "", &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "start_time_type and end_time_type must be FIXED, BMCT, or ECET",
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
}

func normalizeBaseShiftTimeTypeOrFixed(raw string) string {
	value, errObj := normalizeBaseShiftTimeType(raw, "")
	if errObj != nil {
		return strings.ToUpper(strings.TrimSpace(raw))
	}
	return value
}

func float64OrDefault(v *float64, d float64) float64 {
	if v == nil {
		return d
	}
	return *v
}

func extractBaseShiftRangeTokens(raw string) (string, string) {
	tokens := extractBaseShiftClockTokens(raw)
	if len(tokens) == 0 {
		return "", ""
	}
	if len(tokens) == 1 {
		return tokens[0], tokens[0]
	}
	return tokens[0], tokens[1]
}

func extractBaseShiftClockTokens(raw string) []string {
	s := strings.TrimSpace(raw)
	if s == "" {
		return nil
	}
	out := make([]string, 0, 2)
	for i := 0; i < len(s) && len(out) < 2; i++ {
		if i+5 > len(s) || !isDigitBaseClock(s[i]) || !isDigitBaseClock(s[i+1]) || s[i+2] != ':' || !isDigitBaseClock(s[i+3]) || !isDigitBaseClock(s[i+4]) {
			continue
		}
		token := s[i : i+5]
		if i+8 <= len(s) && s[i+5] == ':' && isDigitBaseClock(s[i+6]) && isDigitBaseClock(s[i+7]) {
			token = s[i : i+8]
		}
		out = append(out, token)
	}
	return out
}

func isDigitBaseClock(b byte) bool {
	return b >= '0' && b <= '9'
}

func clockStringOrEmpty(v any) string {
	switch t := v.(type) {
	case nil:
		return ""
	case string:
		return normalizeBaseShiftDisplay(t)
	case *time.Time:
		if t == nil {
			return ""
		}
		return normalizeBaseShiftDisplay(t.Format("15:04:05"))
	default:
		return ""
	}
}

func normalizeBaseShiftDisplay(raw string) string {
	s := strings.TrimSpace(raw)
	if len(s) >= 5 && isDigitBaseClock(s[0]) && isDigitBaseClock(s[1]) && s[2] == ':' && isDigitBaseClock(s[3]) && isDigitBaseClock(s[4]) {
		return s[:5]
	}
	return s
}

func baseCategoryFromRowOrType(row *base.Base, categoryType string) string {
	if row != nil {
		key := strings.TrimSpace(row.BaseCategory.Key)
		if key != "" {
			if fromRelation, ok := base.NormalizeCategoryType(key); ok {
				return fromRelation
			}
		}
	}
	if fromType, ok := base.NormalizeCategoryType(categoryType); ok {
		return fromType
	}
	return base.CategoryKeyRegular
}

func parseBaseCategoryTypeQuery(c *fiber.Ctx) (string, *jsonapi.ErrorObject) {
	raw := baseCategoryQueryValue(c)
	categoryType, ok := base.NormalizeCategoryType(raw)
	if !ok {
		return "", &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "category_type/base_category must be one of: regular, hems",
			Source: &jsonapi.ErrorSource{Pointer: "/query/category_type"},
		}
	}
	return categoryType, nil
}

func parseOptionalBaseCategoryTypeQuery(c *fiber.Ctx) (string, *jsonapi.ErrorObject) {
	raw := baseCategoryQueryValue(c)
	if raw == "" {
		return "", nil
	}
	categoryType, ok := base.NormalizeCategoryType(raw)
	if !ok {
		return "", &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "category_type/base_category must be one of: regular, hems",
			Source: &jsonapi.ErrorSource{Pointer: "/query/category_type"},
		}
	}
	return categoryType, nil
}

func parseOptionalBoolQueryParam(c *fiber.Ctx, queryKey string) (*bool, *jsonapi.ErrorObject) {
	raw := strings.TrimSpace(c.Query(queryKey))
	if raw == "" {
		return nil, nil
	}
	switch strings.ToLower(raw) {
	case "true", "1":
		v := true
		return &v, nil
	case "false", "0":
		v := false
		return &v, nil
	default:
		return nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: queryKey + " must be true or false",
			Source: &jsonapi.ErrorSource{Pointer: "/query/" + queryKey},
		}
	}
}

func baseCategoryQueryValue(c *fiber.Ctx) string {
	raw := strings.TrimSpace(c.Query("category_type"))
	if raw == "" {
		raw = strings.TrimSpace(c.Query("base_category"))
	}
	if raw == "" {
		raw = strings.TrimSpace(c.Query("category"))
	}
	return raw
}

func resolveBaseCategoryType(c *fiber.Ctx, bodyCategory *string, pointer string) (string, *jsonapi.ErrorObject) {
	if bodyCategory != nil {
		categoryType, ok := base.NormalizeCategoryType(*bodyCategory)
		if !ok {
			return "", &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "base_category must be one of: regular, hems",
				Source: &jsonapi.ErrorSource{Pointer: pointer},
			}
		}
		return categoryType, nil
	}
	return parseBaseCategoryTypeQuery(c)
}

func categoryNotFoundDetail(categoryType string) string {
	if categoryType == base.CategoryKeyHEMS {
		return "hems base not found"
	}
	return "base not found"
}

func parseOptionalUUID(v *string, pointer string) ([]byte, *jsonapi.ErrorObject) {
	if v == nil {
		return nil, nil
	}
	s := strings.TrimSpace(*v)
	if s == "" {
		return nil, nil
	}
	parsed, err := uuidv7.ParseString(s)
	if err != nil {
		return nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
	return parsed, nil
}

func parseUUIDOrClear(v string, pointer string) ([]byte, *jsonapi.ErrorObject) {
	s := strings.TrimSpace(v)
	if s == "" {
		return nil, nil
	}
	parsed, err := uuidv7.ParseString(s)
	if err != nil {
		return nil, &jsonapi.ErrorObject{
			Status: "422",
			Title:  "Validation error",
			Detail: "invalid UUID",
			Source: &jsonapi.ErrorSource{Pointer: pointer},
		}
	}
	return parsed, nil
}

func bytesUUIDToString(raw []byte) string {
	if len(raw) == 0 {
		return ""
	}
	if name := userctx.GetDisplayName(context.TODO(), raw); name != "" {
		return name
	}
	s, err := uuidv7.BytesToString(raw)
	if err != nil {
		return ""
	}
	return s
}

func writeBaseErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
	statusStr := strconv.Itoa(status)
	enriched := make([]jsonapi.ErrorObject, 0, len(errs))
	for i := range errs {
		e := errs[i]
		e.Status = statusStr
		def := inferBaseDef(status, e.Title, e.Detail)
		if strings.TrimSpace(e.Code) == "" {
			e.Code = def.Code
		}
		if strings.TrimSpace(e.ErrorCode) == "" {
			e.ErrorCode = def.ErrorCode
		}
		if strings.TrimSpace(e.Title) == "" {
			e.Title = def.Title
		}
		if e.Source != nil && shouldPreserveBaseDetail(e.Source.Pointer) {
			if strings.TrimSpace(e.Detail) == "" {
				e.Detail = def.Message
			}
		} else {
			e.Detail = def.Message
		}
		enriched = append(enriched, e)
	}
	return response.WriteErrors(c, status, enriched)
}

func shouldPreserveBaseDetail(pointer string) bool {
	lowerPointer := strings.ToLower(strings.TrimSpace(pointer))
	return strings.Contains(lowerPointer, "operational_shift_times") ||
		strings.Contains(lowerPointer, "latitude") ||
		strings.Contains(lowerPointer, "longitude") ||
		strings.Contains(lowerPointer, "twilight") ||
		strings.Contains(lowerPointer, "shift_time")
}

func inferBaseDef(status int, title, detail string) apperrorsx.Def {
	lowerTitle := strings.ToLower(strings.TrimSpace(title))
	lowerDetail := strings.ToLower(strings.TrimSpace(detail))

	switch status {
	case fiber.StatusNotFound:
		return apperrorsx.ErrBaseNotFound
	case fiber.StatusConflict:
		return apperrorsx.ErrBaseRelationDelete
	case fiber.StatusUnprocessableEntity:
		switch {
		case strings.Contains(lowerDetail, "uuid is invalid uuid"), strings.Contains(lowerDetail, "invalid uuid"):
			return apperrorsx.ErrBaseInvalidUUID
		case strings.Contains(lowerDetail, "base_category must be one of"),
			strings.Contains(lowerDetail, "category_type/base_category must be one of"):
			return apperrorsx.ErrBaseInvalidCategory
		case strings.Contains(lowerDetail, "at least one attribute must be provided"):
			return apperrorsx.ErrBaseAttributesRequired
		case strings.Contains(lowerDetail, "base is required"), strings.Contains(lowerDetail, "base cannot be empty"):
			return apperrorsx.ErrBaseNameRequired
		case strings.Contains(lowerDetail, "default_shift_start is required"):
			return apperrorsx.ErrBaseDefaultShiftStartRequired
		case strings.Contains(lowerDetail, "default_shift_end is required"):
			return apperrorsx.ErrBaseDefaultShiftEndRequired
		case strings.Contains(lowerDetail, "time must be"), strings.Contains(lowerDetail, "default_shift_time must contain"):
			return apperrorsx.ErrBaseInvalidTime
		case strings.Contains(lowerDetail, "contact id"), strings.Contains(lowerDetail, "value is invalid uuid"):
			return apperrorsx.ErrBaseInvalidContactIDs
		case strings.Contains(lowerDetail, "shift_time.start must be earlier than shift_time.end"),
			strings.Contains(lowerDetail, "shift_time.start is required when start_time_type is fixed"),
			strings.Contains(lowerDetail, "shift_time.end is required when end_time_type is fixed"),
			strings.Contains(lowerDetail, "latitude"),
			strings.Contains(lowerDetail, "longitude"),
			strings.Contains(lowerDetail, "coordinates"),
			strings.Contains(lowerDetail, "twilight"):
			def := apperrorsx.ErrBaseInvalidPayload
			def.Message = detail
			return def
		default:
			return apperrorsx.ErrBaseInvalidPayload
		}
	case fiber.StatusBadRequest:
		switch {
		case strings.Contains(lowerTitle, "invalid json"):
			return apperrorsx.ErrBaseInvalidJSON
		case strings.Contains(lowerTitle, "create failed"):
			return apperrorsx.ErrBaseCreateFailed
		case strings.Contains(lowerTitle, "update failed"):
			return apperrorsx.ErrBaseUpdateFailed
		case strings.Contains(lowerTitle, "delete failed"):
			return apperrorsx.ErrBaseDeleteFailed
		case strings.Contains(lowerTitle, "list failed"):
			return apperrorsx.ErrBaseListFailed
		default:
			return apperrorsx.ErrBaseInvalidPayload
		}
	default:
		return apperrorsx.ErrInternal
	}
}

func inferBaseErrorCode(status int, title, detail string) (string, string) {
	def := inferBaseDef(status, title, detail)
	return def.Code, def.ErrorCode
}
