package handlers

import (
	"context"
	"log/slog"
	"runtime/debug"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/gofiber/fiber/v2"

	actionsignoff "wucher/internal/domain/action_signoff"
	complaint "wucher/internal/domain/complaint"
	fleethistory "wucher/internal/domain/fleet_history"
	"wucher/internal/domain/helicopter"
	"wucher/internal/shared/pkg/apperrorsx"
	"wucher/internal/shared/pkg/userctx"
	"wucher/internal/shared/pkg/uuidv7"
	requestdto "wucher/internal/transport/http/dto/request"
	responsedto "wucher/internal/transport/http/dto/response"
	shareddto "wucher/internal/transport/http/dto/shared"
	"wucher/internal/transport/http/jsonapi"
	"wucher/internal/transport/http/response"
	"wucher/internal/transport/http/validators"
)

type ComplaintHandler struct {
	svc           complaint.Service
	helicopterSvc helicopter.Service
	mcfSvc        mcfPendingChecker
	historySvc    fleetHistoryRecorder
	aogRecalc     aogRecalculator
	signoffSvc    complaintSignoffManager
	validate      *validators.Validator
}

// complaintSignoffLookup batch-loads the corrective-action sign-offs for complaints,
// keyed by complaint id — the sign state now lives in action_signoff, not on the complaint.
type complaintSignoffLookup interface {
	GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*actionsignoff.ActionSignoff, error)
}

// complaintSignoffManager adds the write used when a complaint is updated: reopening a
// (previously signed) complaint invalidates its sign-off, so it is unsigned.
type complaintSignoffManager interface {
	complaintSignoffLookup
	UnsignByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error)
}

type mcfPendingChecker interface {
	PendingHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]bool, error)
}

type aogRecalculator interface {
	RecalculateAOGByHelicopter(ctx context.Context, helicopterID []byte) error
}

func mcfPendingHelicopterSet(ctx context.Context, svc mcfPendingChecker, ids [][]byte) map[string]bool {
	if svc == nil || len(ids) == 0 {
		return map[string]bool{}
	}
	if set, err := svc.PendingHelicopterIDs(ctx, ids); err == nil && set != nil {
		return set
	}
	return map[string]bool{}
}

func NewComplaintHandler(svc complaint.Service) *ComplaintHandler {
	return &ComplaintHandler{svc: svc, validate: validators.New()}
}

func (h *ComplaintHandler) WithHelicopterService(svc helicopter.Service) *ComplaintHandler {
	h.helicopterSvc = svc
	return h
}

func (h *ComplaintHandler) WithMCFService(svc mcfPendingChecker) *ComplaintHandler {
	h.mcfSvc = svc
	return h
}

func (h *ComplaintHandler) WithAOGRecalculator(svc aogRecalculator) *ComplaintHandler {
	h.aogRecalc = svc
	return h
}

func (h *ComplaintHandler) WithHistoryService(svc fleetHistoryRecorder) *ComplaintHandler {
	h.historySvc = svc
	return h
}

func (h *ComplaintHandler) WithActionSignoffService(svc complaintSignoffManager) *ComplaintHandler {
	h.signoffSvc = svc
	return h
}

func (h *ComplaintHandler) complaintResource(ctx context.Context, row *complaint.Complaint, names map[string]string, signoffs map[string]*actionsignoff.ActionSignoff) responsedto.ComplaintResource {
	if row == nil {
		return responsedto.ComplaintResource{}
	}
	signoff := signoffs[string(row.ID)]
	id, _ := uuidv7.BytesToString(row.ID)
	reportedAt := ""
	if !row.ReportedAt.IsZero() {
		reportedAt = row.ReportedAt.UTC().Format(time.RFC3339)
	}
	createdAt := ""
	if !row.CreatedAt.IsZero() {
		createdAt = row.CreatedAt.UTC().Format(time.RFC3339)
	}
	updatedAt := ""
	if !row.UpdatedAt.IsZero() {
		updatedAt = row.UpdatedAt.UTC().Format(time.RFC3339)
	}
	out := responsedto.ComplaintResource{
		Type: "complaint",
		ID:   id,
		Attributes: responsedto.ComplaintAttributes{
			HelicopterID:          idString(row.HelicopterID),
			FlightID:              idString(row.FlightID),
			Description:           row.Description,
			ReportedBy:            names[string(row.ReportedBy)],
			ReportedByShort:       userctx.GetShortName(ctx, row.ReportedBy),
			ReportedAt:            reportedAt,
			AircraftHoursAtReport: row.AircraftHoursAtReport,
			MEL: responsedto.ComplaintMEL{
				Severity: row.MELSeverity,
			},
			NSR: responsedto.ComplaintNSR{
				IsNSR:  row.IsNSR,
				Reason: row.NSRReason,
			},
			ActionSigned:       signoff.IsSigned(),
			ActionTaken:        row.ActionTaken,
			AircraftHoursAtFix: row.AircraftHoursAtFix,
			Status:             row.Status(),
			CreatedAt:          createdAt,
			UpdatedAt:          updatedAt,
		},
	}
	if n := names[string(row.MELClassifiedBy)]; n != "" {
		out.Attributes.MEL.ClassifiedByName = &n
	}
	if s := userctx.GetShortName(ctx, row.MELClassifiedBy); s != "" {
		out.Attributes.MEL.ClassifiedByShort = &s
	}
	if signoff != nil {
		if n := names[string(signoff.SignedBy)]; n != "" {
			out.Attributes.ActionSignedByName = &n
		}
		if s := userctx.GetShortName(ctx, signoff.SignedBy); s != "" {
			out.Attributes.ActionSignedByShort = &s
		}
	}
	// Only show a severity label once classified — a pending complaint's severity 0 is a
	// default, not a "Non-MEL" decision, so it must not render as such.
	if row.MELClassifiedAt != nil {
		v := row.MELClassifiedAt.UTC().Format(time.RFC3339)
		out.Attributes.MEL.ClassifiedAt = &v
		if label := complaintSeverityLabel(row.MELSeverity); label != "" {
			out.Attributes.MEL.SeverityLabel = &label
		}
	}
	if dl := row.MELDeadline(); dl != nil {
		v := dl.UTC().Format(time.RFC3339)
		out.Attributes.MEL.Deadline = &v
	}
	if row.NSRDecidedAt != nil {
		v := row.NSRDecidedAt.UTC().Format(time.RFC3339)
		out.Attributes.NSR.DecidedAt = &v
	}
	if n := names[string(row.NSRDecidedBy)]; n != "" {
		out.Attributes.NSR.DecidedByName = &n
	}
	if s := userctx.GetShortName(ctx, row.NSRDecidedBy); s != "" {
		out.Attributes.NSR.DecidedByShort = &s
	}
	if signoff != nil && signoff.SignedAt != nil {
		v := signoff.SignedAt.UTC().Format(time.RFC3339)
		out.Attributes.ActionSignedAt = &v
	}
	return out
}

func complaintHelicopterKey(row *complaint.Complaint) string {
	if row == nil || len(row.HelicopterID) == 0 {
		return "unknown"
	}
	return string(row.HelicopterID)
}

// CreateComplaint godoc
// @Summary Create complaint
// @Description Create a complaint (defect) for a flight inspection.
// @Description MEL severity (mel_severity, OPTIONAL): omit it to file a "pending_mel" complaint and classify later (via PATCH /complaints/update/{id}); it MUST be classified before recording action taken. A pending complaint does NOT ground the aircraft — grounding starts at classification. Values: 0 = NON-MEL (grounds immediately); 1 = A (1 day grace); 2 = B (3 days); 3 = C (10 days); 4 = D (120 days). After the grace period passes without action the aircraft becomes AOG.
// @Description NSR (Non-Safety Release) is its own flow — sign it at POST /api/v1/complaints/nsr/sign/{id} (cancel at /nsr/unsign/{id}); it is no longer set via create/update.
// @Description Optional fields: aircraft_hours_at_report.
// @Description To record the corrective action, use POST /api/v1/complaints/action-taken/create/{id} (a separate, explicitly-signed step) — it is no longer part of create/update.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param request body requestdto.ComplaintCreateRequest true "JSON:API complaint create request"
// @Success 201 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/create [post]
func (h *ComplaintHandler) Create(c *fiber.Ctx) error {
	var req requestdto.ComplaintCreateRequest
	if err := c.BodyParser(&req); err != nil {
		return writeBodyParseError(c, err, apperrorsx.ErrComplaintInvalidJSON)
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	helicopterID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.HelicopterID))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}
		return apperrorsx.Write(c, appErr)
	}
	var flightID []byte
	if raw := strings.TrimSpace(req.Data.Attributes.FlightID); raw != "" {
		flightID, err = uuidv7.ParseString(raw)
		if err != nil {
			appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
			appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"}
			return apperrorsx.Write(c, appErr)
		}
	}
	// An aircraft may carry several open defects at once (each its own hold item with
	// its own MEL grace), so a new complaint is NOT blocked by existing open ones —
	// consistent with the AOG/gate logic which already handles a list of open complaints.
	// NSR is not decided here — it is its own flow (POST /complaints/nsr/sign/{id}).
	row := &complaint.Complaint{
		HelicopterID:          helicopterID,
		FlightID:              flightID,
		Description:           strings.TrimSpace(req.Data.Attributes.Description),
		ReportedBy:            actorUserID(c),
		ReportedAt:            time.Now().UTC(),
		AircraftHoursAtReport: req.Data.Attributes.AircraftHoursAtReport,
		CreatedBy:             actorUserID(c),
		UpdatedBy:             actorUserID(c),
	}
	if row.Description == "" {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/description"}
		return apperrorsx.Write(c, appErr)
	}
	now := time.Now().UTC()
	actor := actorUserID(c)
	// mel_severity is optional at creation: when provided the complaint is classified
	// immediately; when omitted it stays "pending_mel" (mel_classified_at NULL) — it does
	// NOT ground the aircraft yet (an unassessed report isn't a grounding defect), and it
	// must be classified before action taken can be recorded.
	if req.Data.Attributes.MELSeverity != nil {
		row.MELSeverity = *req.Data.Attributes.MELSeverity
		row.MELClassifiedAt = &now
		row.MELClassifiedBy = append([]byte(nil), actor...)
	}
	if err := h.svc.Create(c.UserContext(), row); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintCreateFailed))
	}
	created, _ := h.svc.GetByID(c.UserContext(), row.ID)
	if created == nil {
		created = row
	}
	h.recordComplaintFiled(c, created)
	if h.aogRecalc != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), created.HelicopterID)
	}
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*created})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*created}, names)
	res := h.complaintResource(c.UserContext(), created, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), created.HelicopterID)
	return response.Write(c, fiber.StatusCreated, responsedto.ComplaintResponse{Data: res})
}

// GetComplaint godoc
// @Summary Get complaint by ID
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/get/{id} [get]
func (h *ComplaintHandler) GetByID(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}}})
	}
	row, err := h.svc.GetByID(c.UserContext(), id)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
	}
	if row == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
	}
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*row})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*row}, names)
	res := h.complaintResource(c.UserContext(), row, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), row.HelicopterID)
	return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}

// UpdateComplaint godoc
// @Summary Update complaint
// @Description Update a complaint. All attributes optional; only provided fields change.
// @Description mel_severity: 0 = NON-MEL (immediate AOG); 1 = A (1 day); 2 = B (3 days); 3 = C (10 days); 4 = D (120 days) — AOG after grace passes.
// @Description NSR is not set here — it is its own flow: POST /api/v1/complaints/nsr/sign/{id} (and /nsr/unsign/{id}).
// @Description Note: the corrective action is recorded via POST /api/v1/complaints/action-taken/create/{id} (a separate, signed step), not here.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintUpdateRequest true "JSON:API complaint update request"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/update/{id} [patch]
func (h *ComplaintHandler) Update(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
		return apperrorsx.Write(c, appErr)
	}
	var req requestdto.ComplaintUpdateRequest
	if err := c.BodyParser(&req); err != nil {
		if appErr := bodyParseError(err); appErr != nil {
			return apperrorsx.Write(c, appErr)
		}
		return writeComplaintErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	if strings.TrimSpace(req.Data.ID) == "" {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintIDRequired)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/id"}
		return apperrorsx.Write(c, appErr)
	}
	if strings.TrimSpace(req.Data.ID) != strings.TrimSpace(c.Params("id")) {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintIDMismatch)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/id"}
		return apperrorsx.Write(c, appErr)
	}
	row, err := h.svc.GetByID(c.UserContext(), id)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
	}
	if row == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
	}
	// NSR is decided via its own endpoint (POST /complaints/nsr/sign|unsign/{id}); the
	// shared update no longer touches it.
	if req.Data.Attributes.Description != nil {
		v := strings.TrimSpace(*req.Data.Attributes.Description)
		if v == "" {
			appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
			appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/description"}
			return apperrorsx.Write(c, appErr)
		}
		row.Description = v
	}
	if req.Data.Attributes.MELSeverity != nil {
		row.MELSeverity = *req.Data.Attributes.MELSeverity
		now := time.Now().UTC()
		row.MELClassifiedAt = &now
		row.MELClassifiedBy = append([]byte(nil), actorUserID(c)...)
	}
	if req.Data.Attributes.AircraftHoursAtReport != nil {
		row.AircraftHoursAtReport = req.Data.Attributes.AircraftHoursAtReport
	}
	row.UpdatedBy = actorUserID(c)
	// Updating a complaint reopens its lifecycle: a previously closed complaint is
	// unfixed (AOG is recomputed below, re-grounding the aircraft) so a new EASA
	// release is required; the old release stays as history.
	if row.FixedAt != nil {
		row.FixedAt = nil
		row.FixedByEASAID = nil
	}
	if err := h.svc.Update(c.UserContext(), row); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
	}
	// The change invalidates any corrective-action sign-off: it must be re-recorded and
	// re-signed. No-op when the complaint was never signed off.
	if h.signoffSvc != nil {
		_, _ = h.signoffSvc.UnsignByComplaint(c.UserContext(), row.ID)
	}
	if h.aogRecalc != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
	}
	updated, _ := h.svc.GetByID(c.UserContext(), id)
	if updated == nil {
		updated = row
	}
	h.recordComplaintUpdate(c, req.Data.Attributes, updated)
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
	res := h.complaintResource(c.UserContext(), updated, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
	return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}

// ComplaintActionTaken godoc
// @Summary Record the corrective action for a complaint
// @Description Records the corrective action text taken for a complaint. Recording the action does NOT sign it and does NOT lift the AOG grounding: signing the action is a separate step at POST /api/v1/action-signoffs/sign/{helicopter_id} (send complaint_id), and returning the aircraft to service requires a formal release (a signed EASA release / CRS, or an NSR). The complaint stays open until an EASA release fixes it.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintActionTakenRequest true "Action taken payload"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/action-taken/create/{id} [post]
func (h *ComplaintHandler) ActionTaken(c *fiber.Ctx) error {
	return h.applyActionTaken(c, false)
}

// UpdateComplaintActionTaken godoc
// @Summary Update the recorded corrective action for a complaint
// @Description Updates the previously-recorded corrective action text of a complaint. Recording/updating the action does NOT sign it — signing is a separate step at POST /api/v1/action-signoffs/sign/{helicopter_id} (send complaint_id).
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintActionTakenRequest true "Action taken payload"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/action-taken/update/{id} [patch]
func (h *ComplaintHandler) UpdateActionTaken(c *fiber.Ctx) error {
	return h.applyActionTaken(c, true)
}

func (h *ComplaintHandler) applyActionTaken(c *fiber.Ctx, allowClear bool) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
		return apperrorsx.Write(c, appErr)
	}
	var req requestdto.ComplaintActionTakenRequest
	if err := c.BodyParser(&req); err != nil {
		if appErr := bodyParseError(err); appErr != nil {
			return apperrorsx.Write(c, appErr)
		}
		return writeComplaintErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}})
	}
	if errs := h.validate.ValidateStruct(req); errs != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
	}
	action := strings.TrimSpace(req.Data.Attributes.ActionTaken)
	// Empty action_taken clears it. Clearing is allowed only on update and only while the
	// action has not been signed off yet; recording (create) still requires a value.
	if action == "" && !allowClear {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/action_taken"}
		return apperrorsx.Write(c, appErr)
	}

	row, err := h.svc.GetByID(c.UserContext(), id)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
	}
	if row == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
	}
	// MEL must be classified before a corrective action can be recorded: a "pending_mel"
	// defect has no agreed severity/deferral, so acting on it is premature. Classify it
	// first via PATCH /complaints/update/{id} (mel_severity).
	if row.MELClassifiedAt == nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintMELRequired)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/mel_severity"}
		return apperrorsx.Write(c, appErr)
	}

	actor := actorUserID(c)
	// Record only the action text here; signing is done at POST /action-signoffs/sign
	// (with complaint_id). Empty clears it — but a signed-off action cannot be cleared.
	if action == "" {
		if h.signoffSvc != nil {
			if signoffs, serr := h.signoffSvc.GetByComplaintIDs(c.UserContext(), [][]byte{id}); serr == nil {
				if so := signoffs[string(id)]; so != nil && so.IsSigned() {
					appErr := apperrorsx.New(apperrorsx.ErrComplaintActionSignedLocked)
					appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/action_taken"}
					return apperrorsx.Write(c, appErr)
				}
			}
		}
		row.ActionTaken = nil
	} else {
		row.ActionTaken = &action
	}
	if req.Data.Attributes.AircraftHoursAtFix != nil {
		row.AircraftHoursAtFix = emptyToNilString(req.Data.Attributes.AircraftHoursAtFix)
	}
	row.UpdatedBy = actor
	if err := h.svc.Update(c.UserContext(), row); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
	}
	if h.aogRecalc != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
	}
	updated, _ := h.svc.GetByID(c.UserContext(), id)
	if updated == nil {
		updated = row
	}
	if h.historySvc != nil {
		msg := "Action taken cleared"
		if action != "" {
			msg = "Action taken — " + action
		}
		_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionActionTaken, msg, actor)
	}
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
	res := h.complaintResource(c.UserContext(), updated, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
	return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}

// NSRSign godoc
// @Summary Sign a Non-Safety Release (NSR / MEL deferral) for a complaint
// @Description Records an NSR decision on its own flow (separate from complaint update): the aircraft is released to fly despite the open defect and is no longer grounded by it. MEL must be classified first — an unassessed (pending_mel) defect cannot be deferred, so a pending complaint returns 422. The complaint stays OPEN and is only closed by a signed EASA release. Requires the complaint.nsr permission.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintNSRSignRequest false "Optional NSR reason"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/nsr/sign/{id} [post]
func (h *ComplaintHandler) NSRSign(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
		return apperrorsx.Write(c, appErr)
	}
	var req requestdto.ComplaintNSRSignRequest
	// Body is optional (reason only); tolerate an empty body.
	_ = c.BodyParser(&req)
	row, err := h.svc.GetByID(c.UserContext(), id)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
	}
	if row == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
	}
	// NSR is a MEL deferral: the defect must be classified before it can be deferred.
	if row.MELClassifiedAt == nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintMELRequired)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/mel_severity"}
		return apperrorsx.Write(c, appErr)
	}
	now := time.Now().UTC()
	actor := actorUserID(c)
	row.IsNSR = true
	row.NSRDecidedAt = &now
	row.NSRDecidedBy = append([]byte(nil), actor...)
	if req.Data.Attributes.NSRReason != nil {
		row.NSRReason = emptyToNilString(req.Data.Attributes.NSRReason)
	}
	row.UpdatedBy = actor
	if err := h.svc.Update(c.UserContext(), row); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
	}
	if h.aogRecalc != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
	}
	updated, _ := h.svc.GetByID(c.UserContext(), id)
	if updated == nil {
		updated = row
	}
	if h.historySvc != nil {
		_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionNSRDone, nsrLogMessage(updated), actor)
	}
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
	res := h.complaintResource(c.UserContext(), updated, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
	return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}

// NSRUnsign godoc
// @Summary Cancel a Non-Safety Release for a complaint
// @Description Revokes the NSR decision: the deferral is lifted, so the still-open defect grounds the aircraft again (AOG is recomputed). Requires the complaint.nsr permission.
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/nsr/unsign/{id} [post]
func (h *ComplaintHandler) NSRUnsign(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
		return apperrorsx.Write(c, appErr)
	}
	row, err := h.svc.GetByID(c.UserContext(), id)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
	}
	if row == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
	}
	actor := actorUserID(c)
	row.IsNSR = false
	row.NSRDecidedAt = nil
	row.NSRDecidedBy = nil
	row.NSRReason = nil
	row.UpdatedBy = actor
	if err := h.svc.Update(c.UserContext(), row); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
	}
	if h.aogRecalc != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
	}
	updated, _ := h.svc.GetByID(c.UserContext(), id)
	if updated == nil {
		updated = row
	}
	if h.historySvc != nil {
		_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionNSRCancelled, "NSR cancelled", actor)
	}
	names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
	signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
	res := h.complaintResource(c.UserContext(), updated, names, signoffs)
	res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
	return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}

// DeleteComplaint godoc
// @Summary Delete complaint
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} jsonapi.Document
// @Failure 404 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/delete/{id} [delete]
func (h *ComplaintHandler) Delete(c *fiber.Ctx) error {
	id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
		return apperrorsx.Write(c, appErr)
	}
	existing, _ := h.svc.GetByID(c.UserContext(), id)
	if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintDeleteFailed))
	}
	if h.aogRecalc != nil && existing != nil {
		_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), existing.HelicopterID)
	}
	return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"type": "complaint_delete", "attributes": map[string]bool{"deleted": true}}})
}

// ListComplaints godoc
// @Summary List complaints
// @Description Lists complaints grouped by helicopter. Filter by date range on the reported date, by the name of an involved person (reporter / MEL classifier / action signer / NSR decider — i.e. pilot/techniker), and by free-text search on the complaint description.
// @Tags Complaints
// @Produce json
// @Param search query string false "Search complaint description"
// @Param date_from query string false "Reported date lower bound (YYYY-MM-DD)"
// @Param date_to query string false "Reported date upper bound (YYYY-MM-DD)"
// @Param person query string false "Involved person name (pilot/techniker, partial match)"
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7)"
// @Param sort query string false "Sort complaints"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Router /api/v1/complaints/get-all [get]
func (h *ComplaintHandler) List(c *fiber.Ctx) (err error) {
	if h == nil || h.svc == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
	}
	defer func() {
		if r := recover(); r != nil {
			slog.ErrorContext(c.UserContext(), "complaint list panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
			err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
		}
	}()
	page := intOrDefaultFromString(c.Query("page"), 1)
	if page < 1 {
		page = 1
	}
	size := intOrDefaultFromString(c.Query("page_size"), 20)
	if size < 1 {
		size = 20
	}
	filter, verr := complaintListFilterFromQuery(c)
	if verr != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
	}
	groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), 0, 0, nil)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
	}
	return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
		"page_number": page,
		"page_size":   size,
		"total":       total,
	})
}

// ListHoldItems godoc
// @Summary List hold items (open MEL-deferred defects)
// @Description Returns the Hold Item List (HIL): complaints that are still OPEN (not yet fixed by an EASA release) AND MEL-deferred (mel_severity > 0). Non-MEL defects (which ground the aircraft immediately) and already-serviced complaints are excluded. Same grouped shape and filters as get-all.
// @Tags Complaints
// @Produce json
// @Param date_from query string false "From date — reported_at >= (YYYY-MM-DD). Legacy 'Von'."
// @Param date_to query string false "To date — reported_at <= (YYYY-MM-DD). Legacy 'bis'."
// @Param person query string false "Pilot/Technician name — matches reporter or action signer. Legacy 'Pilot/Techniker'."
// @Param search query string false "Search complaint description / MEL. Legacy 'Complains'."
// @Param helicopter_id query string false "Helicopter ID (UUIDv7)"
// @Param page query int false "Page number (default 1)"
// @Param page_size query int false "Page size (default 20)"
// @Param sort query string false "Sort expression (e.g. reported_at DESC)"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/hold-items/get-all [get]
func (h *ComplaintHandler) ListHoldItems(c *fiber.Ctx) (err error) {
	if h == nil || h.svc == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
	}
	defer func() {
		if r := recover(); r != nil {
			slog.ErrorContext(c.UserContext(), "complaint hold-items panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
			err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
		}
	}()
	page := intOrDefaultFromString(c.Query("page"), 1)
	if page < 1 {
		page = 1
	}
	size := intOrDefaultFromString(c.Query("page_size"), 20)
	if size < 1 {
		size = 20
	}
	filter, verr := complaintListFilterFromQuery(c)
	if verr != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
	}
	filter.HoldItemsOnly = true
	groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), 0, 0, nil)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
	}
	return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
		"page_number": page,
		"page_size":   size,
		"total":       total,
	})
}

// ListHoldItemsDatatable godoc
// @Summary List hold items (open MEL-deferred defects) — datatable
// @Description Datatable variant of the Hold Item List (open + MEL-deferred complaints). Same filters as /hold-items/get-all (date_from, date_to, person, search, helicopter_id) plus DataTables params (start, length, draw).
// @Tags Complaints
// @Produce json
// @Param date_from query string false "From date — reported_at >= (YYYY-MM-DD). Legacy 'Von'."
// @Param date_to query string false "To date — reported_at <= (YYYY-MM-DD). Legacy 'bis'."
// @Param person query string false "Pilot/Technician name — matches reporter or action signer."
// @Param search query string false "Search complaint description / MEL."
// @Param helicopter_id query string false "Helicopter ID (UUIDv7)"
// @Param start query int false "DataTables start offset"
// @Param length query int false "DataTables page length"
// @Param draw query int false "DataTables draw counter"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/hold-items/get-all/dt [get]
func (h *ComplaintHandler) ListHoldItemsDatatable(c *fiber.Ctx) (err error) {
	if h == nil || h.svc == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
	}
	defer func() {
		if r := recover(); r != nil {
			slog.ErrorContext(c.UserContext(), "complaint hold-items datatable panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
			err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
		}
	}()
	start := intOrDefaultFromString(c.Query("start"), 0)
	length := intOrDefaultFromString(c.Query("length"), 10)
	draw := intOrDefaultFromString(c.Query("draw"), 1)
	filter, verr := complaintListFilterFromQuery(c)
	if verr != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
	}
	filter.HoldItemsOnly = true
	groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), length, start, nil)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
	}
	return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
		"draw":             draw,
		"records_total":    total,
		"records_filtered": total,
	})
}

// @Summary List complaints grouped by helicopter (datatable)
// @Tags Complaints
// @Produce json
// @Param search query string false "Search complaint description"
// @Param date_from query string false "Reported date lower bound (YYYY-MM-DD)"
// @Param date_to query string false "Reported date upper bound (YYYY-MM-DD)"
// @Param person query string false "Involved person name (pilot/techniker, partial match)"
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7)"
// @Param sort query string false "Sort complaints"
// @Param draw query int false "Datatable draw counter" default(1)
// @Param start query int false "Datatable start offset" default(0)
// @Param length query int false "Datatable page length" default(10)
// @Success 200 {object} responsedto.ComplaintGroupedDataTableResponse
// @Router /api/v1/complaints/get-all/dt [get]
func (h *ComplaintHandler) ListDatatable(c *fiber.Ctx) (err error) {
	if h == nil || h.svc == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
	}
	defer func() {
		if r := recover(); r != nil {
			slog.ErrorContext(c.UserContext(), "complaint datatable panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
			err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
		}
	}()
	start := intOrDefaultFromString(c.Query("start"), 0)
	length := intOrDefaultFromString(c.Query("length"), 10)
	draw := intOrDefaultFromString(c.Query("draw"), 1)
	filter, verr := complaintListFilterFromQuery(c)
	if verr != nil {
		return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
	}
	groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), length, start, nil)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
	}
	return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
		"draw":             draw,
		"records_total":    total,
		"records_filtered": total,
	})
}

// @Summary List complaints by helicopter
// @Tags Complaints
// @Produce json
// @Param helicopter_id path string true "Helicopter ID"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Router /api/v1/complaints/get-by-helicopter/{helicopter_id} [get]
func (h *ComplaintHandler) ListByHelicopter(c *fiber.Ctx) (err error) {
	if h == nil || h.svc == nil {
		return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintGroupedListFailed))
	}
	defer func() {
		if r := recover(); r != nil {
			slog.ErrorContext(c.UserContext(), "complaint list by helicopter panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
			err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintGroupedListFailed).WithCause(nil))
		}
	}()
	helicopterID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("helicopter_id")))
	if err != nil {
		appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidHelicopterID).WithCause(err)
		appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"}
		return apperrorsx.Write(c, appErr)
	}
	page := intOrDefaultFromString(c.Query("page"), 1)
	if page < 1 {
		page = 1
	}
	size := intOrDefaultFromString(c.Query("page_size"), 20)
	if size < 1 {
		size = 20
	}
	groups, total, err := h.groupedList(c.UserContext(), complaint.ListFilter{Search: strings.TrimSpace(c.Query("filter"))}, strings.TrimSpace(c.Query("sort")), 0, 0, helicopterID)
	if err != nil {
		return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintGroupedListFailed))
	}
	return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
		"page_number": page,
		"page_size":   size,
		"total":       total,
	})
}

// complaintListFilterFromQuery builds the list filter from query params, supporting
// plain query params (search, date_from, date_to, person, helicopter_id), with the
// legacy `filter` key kept as an alias for search.
// Returns a 422 error object when a date param is not YYYY-MM-DD.
func complaintListFilterFromQuery(c *fiber.Ctx) (complaint.ListFilter, *jsonapi.ErrorObject) {
	q := func(keys ...string) string {
		for _, k := range keys {
			if v := strings.TrimSpace(c.Query(k)); v != "" {
				return v
			}
		}
		return ""
	}
	f := complaint.ListFilter{
		Search:   q("search", "filter"),
		DateFrom: q("date_from"),
		DateTo:   q("date_to"),
		Person:   q("person"),
	}
	if heliRaw := q("helicopter_id"); heliRaw != "" {
		helicopterID, err := uuidv7.ParseString(heliRaw)
		if err != nil {
			return f, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "helicopter_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/helicopter_id"},
			}
		}
		f.HelicopterID = helicopterID
	}
	if flightRaw := q("flight_id"); flightRaw != "" {
		flightID, err := uuidv7.ParseString(flightRaw)
		if err != nil {
			return f, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: "flight_id is invalid UUID",
				Source: &jsonapi.ErrorSource{Pointer: "/flight_id"},
			}
		}
		f.FlightID = flightID
	}
	for _, d := range []struct {
		field string
		value string
	}{{"date_from", f.DateFrom}, {"date_to", f.DateTo}} {
		if d.value == "" {
			continue
		}
		if _, err := time.Parse("2006-01-02", d.value); err != nil {
			return f, &jsonapi.ErrorObject{
				Status: "422",
				Title:  "Validation error",
				Detail: d.field + " must be YYYY-MM-DD",
				Source: &jsonapi.ErrorSource{Pointer: "/" + d.field},
			}
		}
	}
	return f, nil
}

func (h *ComplaintHandler) groupedList(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int, helicopterID []byte) ([]responsedto.ComplaintHelicopterGroupResource, int64, error) {
	if h == nil || h.svc == nil {
		return nil, 0, apperrorsx.New(apperrorsx.ErrComplaintListFailed)
	}
	if len(helicopterID) > 0 {
		rows, total, err := h.svc.ListByHelicopter(ctx, helicopterID, limit, offset)
		if err != nil {
			return nil, 0, err
		}
		return h.groupComplaints(ctx, rows), total, nil
	}
	rows, total, err := h.svc.List(ctx, filter, sort, limit, offset)
	if err != nil {
		return nil, 0, err
	}
	return h.groupComplaints(ctx, rows), total, nil
}

func (h *ComplaintHandler) groupComplaints(ctx context.Context, rows []complaint.Complaint) []responsedto.ComplaintHelicopterGroupResource {
	if len(rows) == 0 {
		return []responsedto.ComplaintHelicopterGroupResource{}
	}
	names := h.resolveComplaintNames(ctx, rows)
	signoffs := h.resolveComplaintSignoffs(ctx, rows, names)
	grouped := map[string]*responsedto.ComplaintHelicopterGroupResource{}
	groupRows := map[string][]complaint.Complaint{}
	order := make([]string, 0)
	for i := range rows {
		row := rows[i]
		key := complaintHelicopterKey(&row)
		g, ok := grouped[key]
		if !ok {
			g = &responsedto.ComplaintHelicopterGroupResource{
				Type: "complaint_helicopter_group",
				Attributes: responsedto.ComplaintHelicopterGroupAttributes{
					Complaints: []responsedto.ComplaintResource{},
				},
			}
			grouped[key] = g
			order = append(order, key)
		}
		res := h.complaintResource(ctx, &row, names, signoffs)
		g.Attributes.Complaints = append(g.Attributes.Complaints, res)
		groupRows[key] = append(groupRows[key], row)
	}
	inUse := h.helicopterInUse(ctx, order)
	helIDs := make([][]byte, 0, len(order))
	for _, key := range order {
		if len(key) == 16 {
			helIDs = append(helIDs, []byte(key))
		}
	}
	mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, helIDs)
	for _, key := range order {
		grouped[key].Attributes.Helicopter = h.complaintHelicopterResource(ctx, []byte(key), groupRows[key], inUse[key], mcfPending[key])
	}
	out := make([]responsedto.ComplaintHelicopterGroupResource, 0, len(order))
	for _, key := range order {
		out = append(out, *grouped[key])
	}
	// Order the helicopter groups by operational status: MCF, AOG, Booked, Available.
	sort.SliceStable(out, func(i, j int) bool {
		return helicopter.StatusRank(complaintGroupStatus(out[i])) < helicopter.StatusRank(complaintGroupStatus(out[j]))
	})
	return out
}

func complaintGroupStatus(g responsedto.ComplaintHelicopterGroupResource) string {
	if g.Attributes.Helicopter != nil {
		return g.Attributes.Helicopter.Status
	}
	return ""
}

// resolveComplaintNames resolves reporter display names for the given complaints,
// keyed by the raw user-id bytes (as string). Returns an empty map on any failure
// so callers can index without nil checks.
func (h *ComplaintHandler) resolveComplaintNames(ctx context.Context, rows []complaint.Complaint) map[string]string {
	if h == nil || h.svc == nil || len(rows) == 0 {
		return map[string]string{}
	}
	if names, err := h.svc.ResolveUserNames(ctx, rows); err == nil && names != nil {
		return names
	}
	return map[string]string{}
}

func (h *ComplaintHandler) resolveComplaintSignoffs(ctx context.Context, rows []complaint.Complaint, names map[string]string) map[string]*actionsignoff.ActionSignoff {
	if h == nil {
		return map[string]*actionsignoff.ActionSignoff{}
	}
	return resolveSignoffsByComplaint(ctx, h.signoffSvc, rows, names)
}

// resolveSignoffsByComplaint batch-loads the corrective-action sign-offs for the given
// complaints (keyed by complaint id bytes as string) and folds each signer's display
// name into names so the resource mappers can render it. Returns an empty map on any
// failure or when svc is nil (sign state simply renders as unsigned).
func resolveSignoffsByComplaint(ctx context.Context, svc complaintSignoffLookup, rows []complaint.Complaint, names map[string]string) map[string]*actionsignoff.ActionSignoff {
	if svc == nil || len(rows) == 0 {
		return map[string]*actionsignoff.ActionSignoff{}
	}
	ids := make([][]byte, 0, len(rows))
	for i := range rows {
		if len(rows[i].ID) == 16 {
			ids = append(ids, rows[i].ID)
		}
	}
	m, err := svc.GetByComplaintIDs(ctx, ids)
	if err != nil || m == nil {
		return map[string]*actionsignoff.ActionSignoff{}
	}
	for _, so := range m {
		if so == nil || len(so.SignedBy) == 0 {
			continue
		}
		if _, ok := names[string(so.SignedBy)]; !ok {
			if n := userctx.GetDisplayName(ctx, so.SignedBy); n != "" {
				names[string(so.SignedBy)] = n
			}
		}
	}
	return m
}

// helicopterInUse batch-resolves the in-use (booked) flag for the helicopter group
// keys (raw id bytes as string). Non-uuid keys (e.g. "unknown") are skipped.
func (h *ComplaintHandler) helicopterInUse(ctx context.Context, keys []string) map[string]bool {
	if h == nil || h.helicopterSvc == nil {
		return map[string]bool{}
	}
	ids := make([][]byte, 0, len(keys))
	for _, k := range keys {
		if len(k) == 16 {
			ids = append(ids, []byte(k))
		}
	}
	if len(ids) == 0 {
		return map[string]bool{}
	}
	if inUse, err := h.helicopterSvc.InUseByAircraftIDs(ctx, ids); err == nil && inUse != nil {
		return inUse
	}
	return map[string]bool{}
}

// complaintHelicopterResource builds the embedded helicopter object for a group:
// full master data (designation/identifier/type), the derived operational status,
// and complaint-derived status reasons. Falls back to an id-only object when the
// helicopter service is unavailable or the lookup fails.
func (h *ComplaintHandler) complaintHelicopterResource(ctx context.Context, helicopterID []byte, complaints []complaint.Complaint, inUse bool, mcfPending bool) *shareddto.FleetStatusHelicopter {
	heliID := idString(helicopterID)
	if heliID == "" {
		return nil
	}
	if h == nil || h.helicopterSvc == nil {
		return &shareddto.FleetStatusHelicopter{ID: heliID}
	}
	heli, err := h.helicopterSvc.GetByID(ctx, helicopterID)
	if err != nil || heli == nil {
		return &shareddto.FleetStatusHelicopter{ID: heliID}
	}
	now := time.Now().UTC()
	status := heli.DeriveStatus(helicopter.StatusInput{
		IsBook:            inUse,
		IsMCF:             mcfPending,
		ComplaintGrounded: anyGrounding(complaints, now),
	})
	res := fleetStatusHelicopterResource(heli, status)
	if res != nil {
		res.StatusReasons = manualAOGReasons(heli)
	}
	return res
}

// complaintHelicopterObject builds the full helicopter object for a single complaint
// response. It pulls the helicopter's open complaints so the derived status (AOG/MCF/
// booked/available) reflects the whole aircraft, not just this one complaint.
func (h *ComplaintHandler) complaintHelicopterObject(ctx context.Context, helicopterID []byte) *shareddto.FleetStatusHelicopter {
	if h == nil || len(helicopterID) != 16 {
		return nil
	}
	var complaints []complaint.Complaint
	if h.svc != nil {
		if rows, _, err := h.svc.ListByHelicopter(ctx, helicopterID, 100, 0); err == nil {
			complaints = rows
		}
	}
	inUse := h.helicopterInUse(ctx, []string{string(helicopterID)})[string(helicopterID)]
	mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, [][]byte{helicopterID})[string(helicopterID)]
	return h.complaintHelicopterResource(ctx, helicopterID, complaints, inUse, mcfPending)
}

// recordComplaintFiled logs the filing event (and an NSR event when filed as NSR).
func (h *ComplaintHandler) recordComplaintFiled(c *fiber.Ctx, row *complaint.Complaint) {
	if h.historySvc == nil || row == nil {
		return
	}
	msg := "Complaint filed: " + row.Description
	if lbl := complaintSeverityLabel(row.MELSeverity); lbl != "" {
		msg += " (MEL " + lbl + ")"
	}
	actor := actorUserID(c)
	_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionComplaintFiled, msg, actor)
	if row.ActionTaken != nil {
		_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionActionTaken, "Action taken — "+*row.ActionTaken, actor)
	}
}

// recordComplaintUpdate logs MEL classification, NSR done/cancel, and action-taken
// events based on which attributes were present in the update request.
func (h *ComplaintHandler) recordComplaintUpdate(c *fiber.Ctx, attrs requestdto.ComplaintUpdateAttributes, row *complaint.Complaint) {
	if h.historySvc == nil || row == nil {
		return
	}
	actor := actorUserID(c)
	if attrs.MELSeverity != nil {
		msg := "MEL classified"
		if lbl := complaintSeverityLabel(*attrs.MELSeverity); lbl != "" {
			msg = "MEL classified as " + lbl
		}
		_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionMELClassified, msg, actor)
	}
}

func nsrLogMessage(row *complaint.Complaint) string {
	if row != nil && row.NSRReason != nil && strings.TrimSpace(*row.NSRReason) != "" {
		return "NSR done — " + strings.TrimSpace(*row.NSRReason)
	}
	return "NSR done"
}

// anyGrounding reports whether any complaint currently grounds the aircraft (AOG):
// a non-MEL defect (immediately) or a MEL past its grace deadline. EASA-fixed
// complaints (signed set) are skipped.
func anyGrounding(rows []complaint.Complaint, now time.Time) bool {
	for i := range rows {
		if rows[i].IsGrounding(now) {
			return true
		}
	}
	return false
}

// complaintGroundingLister fetches open grounding-candidate complaints (non-MEL, or MEL
// not yet released/fixed) for a set of helicopters so any view can derive read-time
// AOG without coupling to the full complaint service.
type complaintGroundingLister interface {
	ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error)
}

// groundingHelicopterSet returns the set of helicopter ids (raw-bytes string key) that
// have at least one complaint currently grounding the aircraft: a non-MEL defect
// (immediately) or a MEL past its grace deadline.
func groundingHelicopterSet(ctx context.Context, svc complaintGroundingLister, ids [][]byte, now time.Time) map[string]bool {
	out := map[string]bool{}
	if svc == nil || len(ids) == 0 {
		return out
	}
	rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, ids)
	if err != nil {
		return out
	}
	for i := range rows {
		if rows[i].IsGrounding(now) {
			out[string(rows[i].HelicopterID)] = true
		}
	}
	return out
}

// groundedHelicopterIDs returns the ids of every helicopter currently grounded by an
// open complaint (per IsGrounding). It lets status filters be AOG-aware, since
// complaint grounding is computed at read time and not persisted in air_on_ground.
func groundedHelicopterIDs(ctx context.Context, svc complaintGroundingLister, now time.Time) [][]byte {
	if svc == nil {
		return nil
	}
	rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, nil)
	if err != nil {
		return nil
	}
	seen := make(map[string]struct{}, len(rows))
	out := make([][]byte, 0, len(rows))
	for i := range rows {
		if !rows[i].IsGrounding(now) {
			continue
		}
		key := string(rows[i].HelicopterID)
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, append([]byte(nil), rows[i].HelicopterID...))
	}
	return out
}

// complaintGroundingReason builds a user-facing grounding reason, distinguishing an
// expired MEL grace period (with its A/B/C/D class) from an immediate non-MEL defect.
func complaintGroundingReason(c complaint.Complaint) string {
	if c.MELSeverity > complaint.MELSeverityNonMEL {
		return "Grounded due to an open complaint — MEL " + shareddto.MELLabel(c.MELSeverity) + " grace period expired"
	}
	return "Grounded due to an open non-MEL complaint"
}

// groundingReasonHelicopterSet maps each grounded helicopter id (raw-bytes string key)
// to a human-readable grounding reason. An absent key means the helicopter is not grounded.
func groundingReasonHelicopterSet(ctx context.Context, svc complaintGroundingLister, ids [][]byte, now time.Time) map[string]string {
	out := map[string]string{}
	if svc == nil || len(ids) == 0 {
		return out
	}
	rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, ids)
	if err != nil {
		return out
	}
	for i := range rows {
		if !rows[i].IsGrounding(now) {
			continue
		}
		key := string(rows[i].HelicopterID)
		if _, exists := out[key]; !exists {
			out[key] = complaintGroundingReason(rows[i])
		}
	}
	return out
}

// manualAOGReasons returns status reasons for the embedded helicopter. Automatic
// grounding (expired MEL) is conveyed by the derived status alone; only a manual
// AOG carries an explanatory reason.
func manualAOGReasons(heli *helicopter.Helicopter) []shareddto.FleetStatusHelicopterReason {
	if heli == nil || !heli.AirOnGround || heli.AOGSource != helicopter.AOGSourceManual {
		return nil
	}
	detail := strings.TrimSpace(heli.AOGReason)
	if detail == "" {
		detail = "Manual AOG"
	}
	return []shareddto.FleetStatusHelicopterReason{{Code: shareddto.ReasonCodeManualAOG, Detail: detail}}
}

// mapComplaintServiceErrorWithFallback translates a raw service/repo error into a
// dedicated complaint AppError. Specific mapped codes (not found, relation delete,
// invalid helicopter reference, invalid payload) pass through with their proper HTTP
// status; anything else collapses to the operation-specific fallback (create/update/...).
func mapComplaintServiceErrorWithFallback(err error, fallback apperrorsx.Def) *apperrorsx.AppError {
	mapped := apperrorsx.Translate(apperrorsx.ModuleComplaint, err)
	if mapped == nil {
		return apperrorsx.New(fallback).WithCause(err)
	}
	switch mapped.Code {
	case apperrorsx.CodeComplaintNotFound,
		apperrorsx.CodeComplaintRelationDelete,
		apperrorsx.CodeComplaintInvalidHelicopterID,
		apperrorsx.CodeComplaintInvalidPayload:
		return mapped
	default:
		return apperrorsx.New(fallback).WithCause(err)
	}
}

func writeComplaintErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
	return jsonapi.WriteErrors(c, status, errs)
}

func intOrDefaultFromString(raw string, def int) int {
	if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
		return v
	}
	return def
}

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