package handlers

import (
	"bytes"
	"context"
	"errors"
	"io"
	"net/http"
	"strings"
	"testing"
	"time"

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

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

type hemsDutyRosterServiceMock struct {
	getHeaderByIDFn      func(id []byte) (*dutyroster.HeaderView, error)
	getCrewsByRosterIDFn func(ids [][]byte) ([]dutyroster.CrewView, error)
	listHeadersByRangeFn func(baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error)
	listHEMSBasesFn      func(baseID []byte) ([]dutyroster.BaseView, error)
	getBaseShiftFn       func(baseID []byte, baseType string) (string, error)
	deleteCrewsFn        func(ctx context.Context, id []byte, crews []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error)

	createErr error
	updateErr error
	deleteErr error

	lastCreateRow   *dutyroster.DutyRoster
	lastCreateCrews []dutyroster.DutyRosterCrew
	lastUpdateRow   *dutyroster.DutyRoster
	lastUpdateCrews []dutyroster.DutyRosterCrew
	lastListBaseID  []byte
	lastListFrom    time.Time
	lastListTo      time.Time
	lastRosterIDs   [][]byte
}

type dutyRosterEmptyErr struct{}

func (dutyRosterEmptyErr) Error() string { return "" }

func (m *hemsDutyRosterServiceMock) Create(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
	m.lastCreateRow = row
	m.lastCreateCrews = crews
	if m.createErr != nil {
		return m.createErr
	}
	if len(row.ID) == 0 {
		row.ID = uuidv7.MustBytes()
	}
	return nil
}

func (m *hemsDutyRosterServiceMock) Update(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
	m.lastUpdateRow = row
	m.lastUpdateCrews = crews
	if m.updateErr != nil {
		return m.updateErr
	}
	return nil
}

func (m *hemsDutyRosterServiceMock) Delete(_ context.Context, _ []byte, _ []byte) error {
	return m.deleteErr
}

func (m *hemsDutyRosterServiceMock) HardDelete(_ context.Context, _ []byte) error {
	return m.deleteErr
}

func (m *hemsDutyRosterServiceMock) DeleteCrews(ctx context.Context, id []byte, crews []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error) {
	if m.deleteCrewsFn != nil {
		return m.deleteCrewsFn(ctx, id, crews, deletedBy)
	}
	return []dutyroster.DeletedCrewResult{}, m.deleteErr
}

func (m *hemsDutyRosterServiceMock) GetBaseDefaultShift(_ context.Context, baseID []byte, baseType string) (string, error) {
	if m.getBaseShiftFn != nil {
		return m.getBaseShiftFn(baseID, baseType)
	}
	return "", nil
}

func (m *hemsDutyRosterServiceMock) ResolveBaseDefaultShiftRange(_ context.Context, baseID []byte, baseType string) (string, string, error) {
	raw, err := m.GetBaseDefaultShift(context.Background(), baseID, baseType)
	if err != nil {
		return "", "", err
	}
	start, end := service.ParseDutyRosterShiftRange(raw)
	return start, end, nil
}

func (m *hemsDutyRosterServiceMock) GetHeaderByID(_ context.Context, id []byte) (*dutyroster.HeaderView, error) {
	if m.getHeaderByIDFn != nil {
		return m.getHeaderByIDFn(id)
	}
	return nil, nil
}

func (m *hemsDutyRosterServiceMock) GetCrewsByRosterIDs(_ context.Context, ids [][]byte) ([]dutyroster.CrewView, error) {
	m.lastRosterIDs = ids
	if m.getCrewsByRosterIDFn != nil {
		return m.getCrewsByRosterIDFn(ids)
	}
	return []dutyroster.CrewView{}, nil
}

func (m *hemsDutyRosterServiceMock) ListHeadersByRange(_ context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) {
	m.lastListBaseID = baseID
	m.lastListFrom = from
	m.lastListTo = to
	if m.listHeadersByRangeFn != nil {
		return m.listHeadersByRangeFn(baseID, from, to)
	}
	return []dutyroster.HeaderView{}, nil
}

func (m *hemsDutyRosterServiceMock) ListHEMSBases(_ context.Context, baseID []byte) ([]dutyroster.BaseView, error) {
	if m.listHEMSBasesFn != nil {
		return m.listHEMSBasesFn(baseID)
	}
	return []dutyroster.BaseView{}, nil
}

func (m *hemsDutyRosterServiceMock) GetHeaderByIDByBaseType(_ context.Context, id []byte, _ string) (*dutyroster.HeaderView, error) {
	return m.GetHeaderByID(context.Background(), id)
}

func (m *hemsDutyRosterServiceMock) ListHeadersByRangeByBaseType(_ context.Context, baseID []byte, from, to time.Time, _ string) ([]dutyroster.HeaderView, error) {
	return m.ListHeadersByRange(context.Background(), baseID, from, to)
}

func (m *hemsDutyRosterServiceMock) ListBasesByType(_ context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) {
	if m.listHEMSBasesFn != nil {
		return m.listHEMSBasesFn(baseID)
	}
	if strings.EqualFold(strings.TrimSpace(baseType), "base") {
		return []dutyroster.BaseView{}, nil
	}
	return []dutyroster.BaseView{{ID: append([]byte(nil), baseID...)}}, nil
}

func newHemsDutyRosterTestApp(h *DutyRosterHandler) *fiber.App {
	app := fiber.New()
	app.Post("/api/v1/hems-roster/create", h.Create)
	app.Patch("/api/v1/hems-roster/update/:uuid", h.Update)
	app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
	app.Get("/api/v1/hems-roster/get-all", h.List)
	app.Get("/api/v1/hems-roster/get-all/dt", h.ListDatatable)
	return app
}

func newDutyRosterFullTestApp(h *DutyRosterHandler) *fiber.App {
	app := fiber.New()
	app.Post("/api/v1/hems-roster/create", h.Create)
	app.Get("/api/v1/hems-roster/get/:uuid", h.Get)
	app.Patch("/api/v1/hems-roster/update/:uuid", h.Update)
	app.Delete("/api/v1/hems-roster/delete/:id", h.Delete)
	app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
	app.Get("/api/v1/hems-roster/get-all", h.List)
	app.Get("/api/v1/hems-roster/get-all/dt", h.ListDatatable)

	app.Post("/api/v1/base-roster/create", h.CreateBase)
	app.Get("/api/v1/base-roster/get/:uuid", h.GetBase)
	app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
	app.Delete("/api/v1/base-roster/delete/:id", h.DeleteBase)
	app.Delete("/api/v1/base-roster/:uuid/crew", h.DeleteBaseCrew)
	app.Get("/api/v1/base-roster/get-all", h.ListBase)
	app.Get("/api/v1/base-roster/get-all/dt", h.ListBaseDatatable)
	return app
}

func doJSONRequestHemsDutyRoster(t *testing.T, app *fiber.App, method, path string, body []byte) (*http.Response, []byte) {
	t.Helper()
	req, err := http.NewRequest(method, path, bytes.NewReader(body))
	if err != nil {
		t.Fatalf("new request: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	respBody, err := io.ReadAll(resp.Body)
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	return resp, respBody
}

func mustUUIDStringHemsDutyRoster(t *testing.T, id []byte) string {
	t.Helper()
	s, err := uuidv7.BytesToString(id)
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}
	return s
}

func TestHemsDutyRosterHandlerUpdate_AllowsMissingDataID(t *testing.T) {
	id := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{
		getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
			return &dutyroster.HeaderView{
				ID:        append([]byte(nil), id...),
				BaseID:    uuidv7.MustBytes(),
				DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt: time.Now().UTC(),
				UpdatedAt: time.Now().UTC(),
			}, nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	body := []byte(`{
		"data": {
			"type": "duty_roster_update",
			"attributes": {
				"shift_time": {"shift_start":"10:00","shift_end":"22:00"}
			}
		}
	}`)
	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
	if svc.lastUpdateRow == nil {
		t.Fatalf("expected update row captured")
	}
}

func TestHemsDutyRosterHandlerUpdate_IDMismatch(t *testing.T) {
	id := uuidv7.MustBytes()
	otherID := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	body := []byte(`{
		"data": {
			"type": "duty_roster_update",
			"id": "` + mustUUIDStringHemsDutyRoster(t, otherID) + `",
			"attributes": {}
		}
	}`)
	resp, respBody := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("expected 404, got %d", resp.StatusCode)
	}
	if !strings.Contains(string(respBody), "id does not match path id") {
		t.Fatalf("expected mismatch error, got %s", string(respBody))
	}
}

func TestHemsDutyRosterHandlerUpdate_RejectsInvalidShiftTimeFormat(t *testing.T) {
	id := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{
		getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
			return &dutyroster.HeaderView{
				ID:        append([]byte(nil), id...),
				BaseID:    uuidv7.MustBytes(),
				DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt: time.Now().UTC(),
				UpdatedAt: time.Now().UTC(),
			}, nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	body := []byte(`{
		"data": {
			"type": "duty_roster_update",
			"attributes": {
				"shift_time": {"shift_start":"10-00","shift_end":"22:00"}
			}
		}
	}`)
	resp, respBody := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("expected 404, got %d", resp.StatusCode)
	}
	if !strings.Contains(string(respBody), "time must be HH:MM or HH:MM:SS") {
		t.Fatalf("expected not found response, got %s", string(respBody))
	}
}

func TestHemsDutyRosterHandlerUpdate_PartialShiftTimeKeepsExistingOtherSide(t *testing.T) {
	id := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{
		getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
			return &dutyroster.HeaderView{
				ID:        append([]byte(nil), id...),
				BaseID:    uuidv7.MustBytes(),
				DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt: time.Now().UTC(),
				UpdatedAt: time.Now().UTC(),
			}, nil
		},
		getBaseShiftFn: func(_ []byte, _ string) (string, error) {
			return "06:00-21:00", nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)
	body := []byte(`{
		"data": {
			"type": "duty_roster_update",
			"attributes": {
				"shift_time": {"shift_start":"10:00"}
			}
		}
	}`)
	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
	if svc.lastUpdateRow == nil {
		t.Fatalf("expected update row captured")
	}
}

func TestBaseDutyRosterHandlerUpdate_PartialShiftTimeKeepsExistingOtherSide(t *testing.T) {
	id := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{
		getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
			return &dutyroster.HeaderView{
				ID:        append([]byte(nil), id...),
				BaseID:    uuidv7.MustBytes(),
				DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt: time.Now().UTC(),
				UpdatedAt: time.Now().UTC(),
			}, nil
		},
		getBaseShiftFn: func(_ []byte, baseType string) (string, error) {
			if baseType != "base" {
				t.Fatalf("expected baseType=base, got %q", baseType)
			}
			return "06:00-21:00", nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)
	body := []byte(`{
		"data": {
			"type": "duty_roster_update",
			"attributes": {
				"shift_time": {"shift_end":"18:30"}
			}
		}
	}`)
	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
	if svc.lastUpdateRow == nil {
		t.Fatalf("expected update row captured")
	}
}

func TestHemsDutyRosterHandlerCreate_RejectsInvalidCrewShiftTime(t *testing.T) {
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	body := []byte(`{
		"data": {
			"type": "duty_roster_create",
			"attributes": {
				"base_id": "` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `",
				"duty_date": "2026-03-20",
				"shift_time": {"shift_start":"06:00","shift_end":"21:00"},
				"roster_detail": {
					"pilot": [{
						"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `",
						"shift_time":{"shift_start":"oops","shift_end":"21:00"}
					}],
					"doctor": [],
					"rescuer": [],
					"helper": [],
					"other_person": []
				}
			}
		}
	}`)
	resp, respBody := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("expected 404, got %d", resp.StatusCode)
	}
	if !strings.Contains(string(respBody), "hems duty roster not found") {
		t.Fatalf("expected not found response, got %s", string(respBody))
	}
}

func TestBuildCrewRows_ShiftTimeBehavior(t *testing.T) {
	actor := uuidv7.MustBytes()

	rows, errObj := service.BuildDutyRosterCrewRows(dtoHemsDetail(
		mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()),
		"",
	), actor)
	if errObj != nil {
		t.Fatalf("unexpected err: %#v", errObj)
	}
	if len(rows) != 1 {
		t.Fatalf("expected 1 row, got %d", len(rows))
	}
	if rows[0].ShiftStart != "" || rows[0].ShiftEnd != "" {
		t.Fatalf("expected empty shift on crew without override, got %q %q", rows[0].ShiftStart, rows[0].ShiftEnd)
	}

	rows, errObj = service.BuildDutyRosterCrewRows(dtoHemsDetail(
		mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()),
		"10:00",
	), actor)
	if errObj != nil {
		t.Fatalf("unexpected err: %#v", errObj)
	}
	if rows[0].ShiftStart != "" {
		t.Fatalf("expected empty crew shift start, got %q", rows[0].ShiftStart)
	}
}

func TestHemsDutyRosterResource_UsesHeaderShiftWhenCrewShiftEmpty(t *testing.T) {
	header := dutyroster.HeaderView{
		ID:        uuidv7.MustBytes(),
		BaseID:    uuidv7.MustBytes(),
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		CreatedAt: time.Now().UTC(),
		UpdatedAt: time.Now().UTC(),
	}
	crews := []dutyroster.CrewView{
		{
			ID:       uuidv7.MustBytes(),
			RoleCode: "doctor",
			Role:     "doctor",
			Name:     "Doc",
		},
	}
	resource := service.DutyRosterResource(context.Background(), header, crews)
	if len(resource.Attributes.Detail.Doctor) != 1 {
		t.Fatalf("expected 1 doctor crew")
	}
	shift := resource.Attributes.Detail.Doctor[0].ShiftTime
	if shift != nil {
		t.Fatalf("expected fallback shift from header, got %#v", shift)
	}
}

func TestNormalizeClockInput(t *testing.T) {
	t.Run("accept HHMM", func(t *testing.T) {
		out, errObj := service.NormalizeDutyRosterClockInput("10:00", "06:00:00", "/x")
		if errObj != nil {
			t.Fatalf("unexpected error: %#v", errObj)
		}
		if out != "10:00:00" {
			t.Fatalf("expected normalized HH:MM:SS, got %q", out)
		}
	})

	t.Run("empty uses default", func(t *testing.T) {
		out, errObj := service.NormalizeDutyRosterClockInput("", "06:00:00", "/x")
		if errObj != nil {
			t.Fatalf("unexpected error: %#v", errObj)
		}
		if out != "06:00:00" {
			t.Fatalf("expected default, got %q", out)
		}
	})

	t.Run("invalid rejected", func(t *testing.T) {
		_, errObj := service.NormalizeDutyRosterClockInput("xx:yy", "06:00:00", "/x")
		if errObj == nil {
			t.Fatalf("expected validation error")
		}
	})
}

func TestHemsDutyRosterHandlerList_IncludesEmptyBases(t *testing.T) {
	baseA := uuidv7.MustBytes()
	baseB := uuidv7.MustBytes()
	rosterID := uuidv7.MustBytes()
	baseAIDStr := mustUUIDStringHemsDutyRoster(t, baseA)
	baseBIDStr := mustUUIDStringHemsDutyRoster(t, baseB)

	svc := &hemsDutyRosterServiceMock{
		listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
			return []dutyroster.HeaderView{{
				ID:               rosterID,
				BaseID:           baseA,
				BaseName:         "Base A",
				BaseAbbreviation: "A",
				DutyDate:         time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt:        time.Now().UTC(),
				UpdatedAt:        time.Now().UTC(),
			}}, nil
		},
		listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
			return []dutyroster.BaseView{
				{ID: baseA, BaseName: "Base A", BaseAbbreviation: "A"},
				{ID: baseB, BaseName: "Base B", BaseAbbreviation: "B"},
			}, nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
	}
	if !strings.Contains(string(body), `"total_bases":2`) {
		t.Fatalf("expected total_bases 2, got %s", string(body))
	}
	if !strings.Contains(string(body), `"id":"`+baseAIDStr+`"`) {
		t.Fatalf("expected base A group in response, got %s", string(body))
	}
	if !strings.Contains(string(body), `"id":"`+baseBIDStr+`"`) {
		t.Fatalf("expected base B group in response, got %s", string(body))
	}
	if !strings.Contains(string(body), `"rosters":[]`) {
		t.Fatalf("expected empty roster group for base without roster, got %s", string(body))
	}
}

func TestHemsDutyRosterHandlerListDatatable_DefaultDateRangeMeta(t *testing.T) {
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(body))
	}
	if !strings.Contains(string(body), `"records_total":0`) {
		t.Fatalf("expected datatable meta, got %s", string(body))
	}
	if !strings.Contains(string(body), `"timezone":"UTC"`) {
		t.Fatalf("expected timezone meta, got %s", string(body))
	}
}

func TestHemsDutyRosterHandlerGetAndDelete(t *testing.T) {
	t.Run("get invalid uuid", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Get("/api/v1/hems-roster/get/:uuid", h.Get)

		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get/not-a-uuid", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("get success", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:        id,
					BaseID:    uuidv7.MustBytes(),
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Get("/api/v1/hems-roster/get/:uuid", h.Get)

		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get/"+mustUUIDStringHemsDutyRoster(t, id), nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("delete not found", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{deleteErr: gorm.ErrRecordNotFound}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Delete("/api/v1/hems-roster/delete/:id", h.Delete)

		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/delete/"+mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()), nil)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterHandlerCreateAndUpdate_ErrorBranches(t *testing.T) {
	t.Run("create invalid json", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", []byte(`{"data":`))
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("create service error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{createErr: errors.New("boom")}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)

		body := []byte(`{
			"data":{"type":"duty_roster_create","attributes":{
				"base_id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `",
				"duty_date":"2026-03-20",
				"shift_time":{"shift_start":"06:00","shift_end":"21:00"},
				"roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}
			}}
		}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("update existing not found", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil }}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+id, body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("update service not found", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			updateErr: gorm.ErrRecordNotFound,
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:        id,
					BaseID:    uuidv7.MustBytes(),
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterHandlerList_ValidationErrors(t *testing.T) {
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	app := newHemsDutyRosterTestApp(h)

	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-10&to=2026-03-01", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}

	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=x&to=2026-03-10", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", resp.StatusCode)
	}
}

func TestBuildCrewRows_ErrorBranches(t *testing.T) {
	actor := uuidv7.MustBytes()
	pilotID := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())

	_ = pilotID

	_, errObj := service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		OtherPerson: []requestdto.DutyRosterOtherPersonInput{{}},
	}, actor)
	if errObj == nil || !strings.Contains(errObj.Detail, "other_person name is required") {
		t.Fatalf("expected other_person name required, got %#v", errObj)
	}

	_, errObj = service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{{ID: "not-uuid"}},
	}, actor)
	if errObj == nil || !strings.Contains(errObj.Detail, "crew id is invalid UUID") {
		t.Fatalf("expected invalid crew id, got %#v", errObj)
	}
}

func TestDutyRosterHelperFunctionsCoverage(t *testing.T) {
	if dateToString(nil) != "" {
		t.Fatalf("expected empty date string")
	}
	now := time.Now().UTC()
	if dateToString(&now) == "" {
		t.Fatalf("expected non-empty date string")
	}
	if bytesEqualUUID([]byte{1, 2}, []byte{1, 2}) != true || bytesEqualUUID([]byte{1, 2}, []byte{1, 3}) != false {
		t.Fatalf("unexpected bytesEqualUUID")
	}
	if bytesEqualUUID([]byte{1}, []byte{1, 2}) {
		t.Fatalf("expected false on different length")
	}
}

func TestDutyRosterHandler_WithAuditAndRequestIDHelpers(t *testing.T) {
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	if h.WithAudit(&service.AuditLogService{}) != h {
		t.Fatalf("expected WithAudit to return same handler pointer")
	}

	app := fiber.New()
	app.Get("/reqid-locals", func(c *fiber.Ctx) error {
		c.Locals("requestid", " req-123 ")
		return c.SendString(requestIDFromCtx(c))
	})
	app.Get("/reqid-header", func(c *fiber.Ctx) error {
		return c.SendString(requestIDFromCtx(c))
	})

	resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/reqid-locals", nil)
	if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "req-123" {
		t.Fatalf("expected request id from locals, got status=%d body=%s", resp.StatusCode, string(body))
	}

	req, _ := http.NewRequest(http.MethodGet, "/reqid-header", nil)
	req.Header.Set("X-Request-ID", " hdr-456 ")
	resp2, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	body2, _ := io.ReadAll(resp2.Body)
	if resp2.StatusCode != http.StatusOK || strings.TrimSpace(string(body2)) != "hdr-456" {
		t.Fatalf("expected request id from header, got status=%d body=%s", resp2.StatusCode, string(body2))
	}
}

func TestDutyRosterHandler_DeleteCrewBranches(t *testing.T) {
	rosterID := uuidv7.MustBytes()
	rosterIDStr := mustUUIDStringHemsDutyRoster(t, rosterID)

	t.Run("invalid uuid", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/not-uuid/crew", []byte(`{}`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid body", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/"+rosterIDStr+"/crew", []byte(`{`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("empty crews", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/"+rosterIDStr+"/crew", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("service error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
				return nil, gorm.ErrRecordNotFound
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `"}]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/"+rosterIDStr+"/crew", body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("no matched rows", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
				return []dutyroster.DeletedCrewResult{}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `"}]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/"+rosterIDStr+"/crew", body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("success delete crew", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
				return []dutyroster.DeletedCrewResult{
					{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot X", RoleCode: "pilot", CrewType: "main"},
				}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Delete("/api/v1/hems-roster/:uuid/crew", h.DeleteCrew)
		body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `"}]}}}}`)
		resp, respBody := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/"+rosterIDStr+"/crew", body)
		if resp.StatusCode != http.StatusOK || !strings.Contains(string(respBody), `"deleted_count":1`) {
			t.Fatalf("expected 200 with deleted_count, got status=%d body=%s", resp.StatusCode, string(respBody))
		}
	})
}

func TestDutyRosterHandler_BuildServiceErrorObjectAndMySQLMappings(t *testing.T) {
	type tc struct {
		name     string
		err      error
		detail   string
		expected int
	}
	cases := []tc{
		{name: "record not found", err: gorm.ErrRecordNotFound, detail: "not found x", expected: http.StatusNotFound},
		{name: "context canceled", err: context.Canceled, expected: http.StatusRequestTimeout},
		{name: "context deadline", err: context.DeadlineExceeded, expected: http.StatusRequestTimeout},
		{name: "mysql duplicate", err: &mysqldriver.MySQLError{Number: 1062, Message: "Duplicate entry 'x' for key 'idx_roster_base_date'"}, expected: http.StatusConflict},
		{name: "mysql validation", err: &mysqldriver.MySQLError{Number: 1292, Message: "bad date"}, expected: http.StatusUnprocessableEntity},
		{name: "mysql default", err: &mysqldriver.MySQLError{Number: 9999, Message: "x"}, expected: http.StatusBadRequest},
	}
	for _, cse := range cases {
		t.Run(cse.name, func(t *testing.T) {
			app := fiber.New()
			app.Get("/", func(c *fiber.Ctx) error {
				status, errObj := buildServiceErrorObject("X", cse.err, cse.detail)
				return response.WriteErrors(c, status, []jsonapi.ErrorObject{errObj})
			})
			resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/", nil)
			if resp.StatusCode != cse.expected {
				t.Fatalf("expected %d, got %d", cse.expected, resp.StatusCode)
			}
		})
	}

	appNil := fiber.New()
	appNil.Get("/", func(c *fiber.Ctx) error {
		status, errObj := buildServiceErrorObject("X", nil, "")
		return response.WriteErrors(c, status, []jsonapi.ErrorObject{errObj})
	})
	resp, _ := doJSONRequestHemsDutyRoster(t, appNil, http.MethodGet, "/", nil)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 for nil error branch, got %d", resp.StatusCode)
	}

	appEmpty := fiber.New()
	appEmpty.Get("/", func(c *fiber.Ctx) error {
		status, errObj := buildServiceErrorObject("fallback title", dutyRosterEmptyErr{}, "")
		return response.WriteErrors(c, status, []jsonapi.ErrorObject{errObj})
	})
	resp2, body2 := doJSONRequestHemsDutyRoster(t, appEmpty, http.MethodGet, "/", nil)
	if resp2.StatusCode != http.StatusBadRequest || !strings.Contains(string(body2), "fallback title") {
		t.Fatalf("expected fallback title detail, got status=%d body=%s", resp2.StatusCode, string(body2))
	}

	if dutyRosterFriendlyMySQLError(1452) == "" || mysqlDuplicateKey("x") != "" {
		t.Fatalf("expected helper mappings to work")
	}

	for _, code := range []uint16{1048, 1292, 1366, 1451, 1452, 3819, 4025, 9999} {
		if strings.TrimSpace(dutyRosterFriendlyMySQLError(code)) == "" {
			t.Fatalf("expected friendly message for mysql code %d", code)
		}
	}
}

func TestDutyRosterHandler_BaseEndpointValidationBranches(t *testing.T) {
	svc := &hemsDutyRosterServiceMock{}
	h := NewDutyRosterHandler(svc)
	app := newDutyRosterFullTestApp(h)

	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get/not-uuid", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 for invalid get base uuid, got %d", resp.StatusCode)
	}

	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/base-roster/delete/not-uuid", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 for invalid delete base uuid, got %d", resp.StatusCode)
	}

	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/base-roster/not-uuid/crew", []byte(`{}`))
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 for invalid delete base crew uuid, got %d", resp.StatusCode)
	}

	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all?base_id=not-uuid", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 for invalid base_id query, got %d", resp.StatusCode)
	}
	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all/dt?base_id=not-uuid", nil)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 for invalid dt base_id query, got %d", resp.StatusCode)
	}
}

func TestDutyRosterHandler_UpdateBaseMoreBranches(t *testing.T) {
	rosterID := uuidv7.MustBytes()
	rosterIDStr := mustUUIDStringHemsDutyRoster(t, rosterID)
	baseID := uuidv7.MustBytes()

	baseHeader := &dutyroster.HeaderView{
		ID:        rosterID,
		BaseID:    baseID,
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		CreatedAt: time.Now().UTC(),
		UpdatedAt: time.Now().UTC(),
	}

	t.Run("not found existing roster", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid base id", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return baseHeader, nil },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"base_id":"bad"}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 for bad base_id, got %d", resp.StatusCode)
		}
	})

	t.Run("service update error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			updateErr: errors.New("update failed"),
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return baseHeader, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid duty date and shift time", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return baseHeader, nil },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"duty_date":"bad"}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 invalid duty_date, got %d", resp.StatusCode)
		}

		body = []byte(`{"data":{"type":"duty_roster_update","attributes":{"shift_time":{"shift_start":"xx","shift_end":"22:00"}}}}`)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 invalid shift, got %d", resp.StatusCode)
		}
	})

	t.Run("resolve default shift error", func(t *testing.T) {
		emptyShiftHeader := *baseHeader
		emptyShiftHeader.ShiftStart = ""
		emptyShiftHeader.ShiftEnd = ""
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return &emptyShiftHeader, nil },
			getBaseShiftFn:  func(_ []byte, _ string) (string, error) { return "", errors.New("shift lookup failed") },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"shift_time":{"shift_start":"08:00","shift_end":"22:00"}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 on shift lookup error, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid crew detail payload", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return baseHeader, nil },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Patch("/api/v1/base-roster/update/:uuid", h.UpdateBase)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"roster_detail":{"pilot":[{"id":"bad"}]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/base-roster/update/"+rosterIDStr, body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 invalid crew id, got %d", resp.StatusCode)
		}
	})
}

func TestDutyRosterHandler_LogCrewAuditBranch(t *testing.T) {
	h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{}).WithAudit(&service.AuditLogService{})
	app := fiber.New()
	app.Post("/audit", func(c *fiber.Ctx) error {
		h.logCrewAudit(c, "duty_roster.crew.append", uuidv7.MustBytes(), "hems", []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}}, "msg")
		return c.SendStatus(http.StatusOK)
	})
	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/audit", []byte(`{}`))
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
}

func TestDutyRosterHandler_RespondByIDBaseTypeBranches(t *testing.T) {
	id := uuidv7.MustBytes()
	idStr := mustUUIDStringHemsDutyRoster(t, id)
	baseID := uuidv7.MustBytes()
	header := &dutyroster.HeaderView{
		ID:               id,
		BaseID:           baseID,
		BaseName:         "Base A",
		BaseAbbreviation: "BA",
		DutyDate:         time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
		CreatedAt:        time.Now().UTC(),
		UpdatedAt:        time.Now().UTC(),
	}

	t.Run("header error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, errors.New("boom") }}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByIDBaseType(c, http.StatusOK, id, "base") })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("header nil", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil }}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByIDBaseType(c, http.StatusOK, id, "base") })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("crews error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn:      func(_ []byte) (*dutyroster.HeaderView, error) { return header, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("boom") },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByIDBaseType(c, http.StatusOK, id, "base") })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("base and hems response", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn:      func(_ []byte) (*dutyroster.HeaderView, error) { return header, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return []dutyroster.CrewView{}, nil },
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/x-base/:uuid", func(c *fiber.Ctx) error { return h.respondByIDBaseType(c, http.StatusOK, id, "base") })
		app.Get("/x-hems/:uuid", func(c *fiber.Ctx) error { return h.respondByIDBaseType(c, http.StatusOK, id, "hems") })
		resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x-base/"+idStr, nil)
		if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"type":"base_duty_roster"`) {
			t.Fatalf("expected base type response, got status=%d body=%s", resp.StatusCode, string(body))
		}
		resp, body = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x-hems/"+idStr, nil)
		if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"type":"duty_roster"`) {
			t.Fatalf("expected duty roster type response, got status=%d body=%s", resp.StatusCode, string(body))
		}
	})
}

func TestDutyRosterHandler_RespondByIDBranches(t *testing.T) {
	id := uuidv7.MustBytes()
	idStr := mustUUIDStringHemsDutyRoster(t, id)
	baseID := uuidv7.MustBytes()
	header := &dutyroster.HeaderView{
		ID:               id,
		BaseID:           baseID,
		BaseName:         "HB",
		BaseAbbreviation: "HB",
		DutyDate:         time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC),
		CreatedAt:        time.Now().UTC(),
		UpdatedAt:        time.Now().UTC(),
	}

	t.Run("header nil", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil }})
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByID(c, http.StatusOK, id) })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("crews error", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{
			getHeaderByIDFn:      func(_ []byte) (*dutyroster.HeaderView, error) { return header, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("boom") },
		})
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByID(c, http.StatusOK, id) })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{
			getHeaderByIDFn:      func(_ []byte) (*dutyroster.HeaderView, error) { return header, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return []dutyroster.CrewView{}, nil },
		})
		app := fiber.New()
		app.Get("/x/:uuid", func(c *fiber.Ctx) error { return h.respondByID(c, http.StatusOK, id) })
		resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/"+idStr, nil)
		if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), `"type":"duty_roster"`) {
			t.Fatalf("expected duty roster response, got status=%d body=%s", resp.StatusCode, string(body))
		}
	})
}

func TestDutyRosterHandler_CreateBaseMoreBranches(t *testing.T) {
	baseID := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
	svc := &hemsDutyRosterServiceMock{
		getHeaderByIDFn:      func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil },
		getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return []dutyroster.CrewView{}, nil },
	}
	h := NewDutyRosterHandler(svc)
	app := fiber.New()
	app.Post("/api/v1/base-roster/create", h.CreateBase)

	resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/base-roster/create", []byte(`{`))
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 invalid json, got %d", resp.StatusCode)
	}

	body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"bad","duty_date":"2026-03-20","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/base-roster/create", body)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 invalid base_id, got %d", resp.StatusCode)
	}

	body = []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + baseID + `","duty_date":"bad","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
	resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/base-roster/create", body)
	if resp.StatusCode != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422 invalid duty_date, got %d", resp.StatusCode)
	}

	svcShiftErr := &hemsDutyRosterServiceMock{
		getBaseShiftFn: func(_ []byte, _ string) (string, error) { return "", errors.New("base shift error") },
	}
	hShiftErr := NewDutyRosterHandler(svcShiftErr)
	appShiftErr := fiber.New()
	appShiftErr.Post("/api/v1/base-roster/create", hShiftErr.CreateBase)
	body = []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + baseID + `","duty_date":"2026-03-20","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
	resp, _ = doJSONRequestHemsDutyRoster(t, appShiftErr, http.MethodPost, "/api/v1/base-roster/create", body)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 on base shift lookup error, got %d", resp.StatusCode)
	}

	svcCreateErr := &hemsDutyRosterServiceMock{
		createErr: errors.New("create fail"),
	}
	hCreateErr := NewDutyRosterHandler(svcCreateErr)
	appCreateErr := fiber.New()
	appCreateErr.Post("/api/v1/base-roster/create", hCreateErr.CreateBase)
	resp, _ = doJSONRequestHemsDutyRoster(t, appCreateErr, http.MethodPost, "/api/v1/base-roster/create", body)
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected 400 on create error, got %d", resp.StatusCode)
	}
}

func TestDutyRosterHandler_DeleteBaseCrewMoreBranches(t *testing.T) {
	rosterID := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())

	t.Run("invalid payload", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := fiber.New()
		app.Delete("/api/v1/base-roster/:uuid/crew", h.DeleteBaseCrew)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/base-roster/"+rosterID+"/crew", []byte(`{`))
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 invalid payload, got %d", resp.StatusCode)
		}
	})

	t.Run("service error and not found", func(t *testing.T) {
		svcErr := &hemsDutyRosterServiceMock{
			deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
				return nil, errors.New("delete failed")
			},
		}
		hErr := NewDutyRosterHandler(svcErr)
		appErr := fiber.New()
		appErr.Delete("/api/v1/base-roster/:uuid/crew", hErr.DeleteBaseCrew)
		body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `"}]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, appErr, http.MethodDelete, "/api/v1/base-roster/"+rosterID+"/crew", body)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 service error, got %d", resp.StatusCode)
		}

		svcNotFound := &hemsDutyRosterServiceMock{
			deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
				return []dutyroster.DeletedCrewResult{}, nil
			},
		}
		hNotFound := NewDutyRosterHandler(svcNotFound)
		appNotFound := fiber.New()
		appNotFound.Delete("/api/v1/base-roster/:uuid/crew", hNotFound.DeleteBaseCrew)
		resp, _ = doJSONRequestHemsDutyRoster(t, appNotFound, http.MethodDelete, "/api/v1/base-roster/"+rosterID+"/crew", body)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404 no match, got %d", resp.StatusCode)
		}
	})
}

func TestDutyRosterHandler_ListBaseMoreBranches(t *testing.T) {
	baseID := uuidv7.MustBytes()
	baseIDStr := mustUUIDStringHemsDutyRoster(t, baseID)
	headerID := uuidv7.MustBytes()

	t.Run("to before from", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := fiber.New()
		app.Get("/api/v1/base-roster/get-all", h.ListBase)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all?from=2026-03-20&to=2026-03-10", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422 to<from, got %d", resp.StatusCode)
		}
	})

	t.Run("list headers error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return nil, errors.New("list headers failed")
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/api/v1/base-roster/get-all", h.ListBase)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all?base_id="+baseIDStr+"&from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 list headers error, got %d", resp.StatusCode)
		}
	})

	t.Run("list bases or crews error", func(t *testing.T) {
		svcBases := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{ID: headerID, BaseID: baseID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}}, nil
			},
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) { return nil, errors.New("bases failed") },
		}
		hBases := NewDutyRosterHandler(svcBases)
		appBases := fiber.New()
		appBases.Get("/api/v1/base-roster/get-all", hBases.ListBase)
		resp, _ := doJSONRequestHemsDutyRoster(t, appBases, http.MethodGet, "/api/v1/base-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 list bases error, got %d", resp.StatusCode)
		}

		svcCrews := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{ID: headerID, BaseID: baseID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}}, nil
			},
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
				return []dutyroster.BaseView{{ID: baseID, BaseName: "B1", BaseAbbreviation: "B1"}}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("crews failed") },
		}
		hCrews := NewDutyRosterHandler(svcCrews)
		appCrews := fiber.New()
		appCrews.Get("/api/v1/base-roster/get-all", hCrews.ListBase)
		resp, _ = doJSONRequestHemsDutyRoster(t, appCrews, http.MethodGet, "/api/v1/base-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 list crews error, got %d", resp.StatusCode)
		}
	})

	t.Run("datatable list errors", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return nil, errors.New("list failed")
			},
		}
		h := NewDutyRosterHandler(svc)
		app := fiber.New()
		app.Get("/api/v1/base-roster/get-all/dt", h.ListBaseDatatable)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all/dt?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400 list dt headers error, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterHandlerGetDeleteAndRespondByID_Branches(t *testing.T) {
	t.Run("get service error", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, errors.New("boom") },
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Get("/api/v1/hems-roster/get/:uuid", h.Get)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get/"+mustUUIDStringHemsDutyRoster(t, id), nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("delete invalid uuid", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Delete("/api/v1/hems-roster/delete/:id", h.Delete)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/delete/not-uuid", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("delete generic error", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{deleteErr: errors.New("x")}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Delete("/api/v1/hems-roster/delete/:id", h.Delete)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/delete/"+mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()), nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("delete success", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		app.Delete("/api/v1/hems-roster/delete/:id", h.Delete)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/hems-roster/delete/"+mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()), nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterHandlerUpdate_Branches(t *testing.T) {
	t.Run("invalid path uuid", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/not-uuid", []byte(`{}`))
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid body json", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()), []byte(`{"data":`))
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid data id uuid", func(t *testing.T) {
		id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","id":"not-uuid","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+id, body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

}

func TestBuildCrewRows_MoreBranches(t *testing.T) {
	actor := uuidv7.MustBytes()
	id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())

	_, errObj := service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{
			{ID: id},
			{ID: id},
		},
	}, actor)
	if errObj == nil || !strings.Contains(errObj.Detail, "duplicate crew assignment") {
		t.Fatalf("expected duplicate error, got %#v", errObj)
	}

	_, errObj = service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{
			{
				ID: id,
				ShiftDate: &requestdto.DutyRosterShiftDate{
					DateStart: "bad-date",
				},
			},
		},
	}, actor)
	if errObj == nil || !strings.Contains(errObj.Detail, "ERR_DUTY_ROSTER_SHIFT_DATE_START_FORMAT") {
		t.Fatalf("expected date_start error, got %#v", errObj)
	}

	_, errObj = service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{
			{
				ID: id,
				ShiftDate: &requestdto.DutyRosterShiftDate{
					DateEnd: "bad-date",
				},
			},
		},
	}, actor)
	if errObj == nil || !strings.Contains(errObj.Detail, "ERR_DUTY_ROSTER_SHIFT_DATE_END_FORMAT") {
		t.Fatalf("expected date_end error, got %#v", errObj)
	}
}

func TestGroupAndMergeHelpers_Branches(t *testing.T) {
	items := []responsedto.DutyRosterResource{
		{
			ID: "r1",
			Attributes: responsedto.DutyRosterAttributes{
				Bases: responsedto.DutyRosterBaseInfo{BaseName: "Unknown"},
			},
		},
	}
	grouped := service.GroupRostersByBase(items)
	if len(grouped) != 1 {
		t.Fatalf("unexpected grouped result: %#v", grouped)
	}

	merged := service.MergeEmptyBaseGroups(grouped, []dutyroster.BaseView{
		{ID: uuidv7.MustBytes(), BaseName: "Base X"},
	})
	if len(merged) != 2 {
		t.Fatalf("expected merged groups 2, got %d", len(merged))
	}

	merged = service.MergeEmptyBaseGroups(grouped, []dutyroster.BaseView{
		{ID: nil, BaseName: "No ID"},
	})
	if len(merged) != 1 {
		t.Fatalf("expected groups unchanged when base id empty, got %d", len(merged))
	}
}

func dtoHemsDetail(userID, shiftStart string) requestdto.DutyRosterDetailsInput {
	item := requestdto.DutyRosterPilotCrewInput{ID: userID}
	return requestdto.DutyRosterDetailsInput{
		Pilot:       []requestdto.DutyRosterPilotCrewInput{item},
		Doctor:      []requestdto.DutyRosterCrewInput{},
		Rescuer:     []requestdto.DutyRosterCrewInput{},
		Helper:      []requestdto.DutyRosterCrewInput{},
		OtherPerson: []requestdto.DutyRosterOtherPersonInput{},
	}
}

func TestHemsDutyRosterHandlerCreate_MoreBranches(t *testing.T) {
	t.Run("validation error", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", []byte(`{}`))
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid base uuid", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"x","duty_date":"2026-03-20","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid duty date", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","duty_date":"20-03-2026","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid header shift start", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","duty_date":"2026-03-20","shift_time":{"shift_start":"bad","shift_end":"21:00"},"roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid header shift end", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","duty_date":"2026-03-20","shift_time":{"shift_start":"06:00","shift_end":"bad"},"roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("success", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:        append([]byte(nil), id...),
					BaseID:    uuidv7.MustBytes(),
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
				return []dutyroster.CrewView{}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","duty_date":"2026-03-20","shift_time":{"shift_start":"10:00","shift_end":"22:00"},"roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if svc.lastCreateRow == nil {
			t.Fatalf("expected create row")
		}
	},
	)

	t.Run("fallback shift from base default", func(t *testing.T) {
		id := uuidv7.MustBytes()
		baseID := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getBaseShiftFn: func(_ []byte, _ string) (string, error) {
				return "09:30-20:15", nil
			},
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:        append([]byte(nil), id...),
					BaseID:    append([]byte(nil), baseID...),
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
				return []dutyroster.CrewView{}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_create","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, baseID) + `","duty_date":"2026-03-20","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/hems-roster/create", body)
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
		if svc.lastCreateRow == nil {
			t.Fatalf("expected create row")
		}
	})
}

func TestHemsDutyRosterHandlerUpdate_MoreBranches(t *testing.T) {
	t.Run("validation error", func(t *testing.T) {
		id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+id, []byte(`{}`))
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("existing fetch error", func(t *testing.T) {
		id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, errors.New("get failed") },
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+id, []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`))
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid base id in attrs", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"base_id":"bad"}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid duty date in attrs", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"duty_date":"20-03-2026"}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid shift end in attrs", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:       id,
					BaseID:   uuidv7.MustBytes(),
					DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"shift_time":{"shift_start":"10:00","shift_end":"bad"}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("invalid hems_detail payload", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","shift_time":{"shift_start":"10:00","shift_end":"bad"}}],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("service generic error", func(t *testing.T) {
		id := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			updateErr: errors.New("boom"),
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("success with attrs", func(t *testing.T) {
		id := uuidv7.MustBytes()
		newBaseID := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{
					ID:        id,
					BaseID:    uuidv7.MustBytes(),
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
				return []dutyroster.CrewView{}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		body := []byte(`{"data":{"type":"duty_roster_update","attributes":{"base_id":"` + mustUUIDStringHemsDutyRoster(t, newBaseID) + `","duty_date":"2026-03-21","roster_detail":{"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}}}}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPatch, "/api/v1/hems-roster/update/"+mustUUIDStringHemsDutyRoster(t, id), body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if svc.lastUpdateRow == nil || !bytesEqualUUID(svc.lastUpdateRow.BaseID, newBaseID) {
			t.Fatalf("expected base id updated")
		}
	})
}

func TestHemsDutyRosterHandlerListAndDatatable_MoreBranches(t *testing.T) {
	t.Run("list invalid base query", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?hems_base_id=bad", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}
	})

	t.Run("list service errors", func(t *testing.T) {
		svc := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) { return nil, errors.New("h") },
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}

		svc = &hemsDutyRosterServiceMock{
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) { return nil, errors.New("b") },
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{}, nil
			},
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}

		headerID := uuidv7.MustBytes()
		svc = &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{ID: headerID, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}}, nil
			},
			listHEMSBasesFn:      func(_ []byte) ([]dutyroster.BaseView, error) { return []dutyroster.BaseView{}, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("c") },
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})

	t.Run("list with crew mapping", func(t *testing.T) {
		headerID := uuidv7.MustBytes()
		baseID := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{
					ID:        headerID,
					BaseID:    baseID,
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}}, nil
			},
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
				return []dutyroster.BaseView{{ID: baseID, BaseName: "B", BaseAbbreviation: "B"}}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
				return []dutyroster.CrewView{{RosterID: headerID, RoleCode: "doctor", Role: "doctor", Name: "Doc"}}, nil
			},
		}
		h := NewDutyRosterHandler(svc)
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("datatable more branches", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{})
		app := newHemsDutyRosterTestApp(h)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?hems_base_id=bad", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}

		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?from=bad", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}

		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?from=2026-03-01&to=bad", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}

		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?from=2026-03-10&to=2026-03-01", nil)
		if resp.StatusCode != http.StatusUnprocessableEntity {
			t.Fatalf("expected 422, got %d", resp.StatusCode)
		}

		baseID := uuidv7.MustBytes()
		headerID := uuidv7.MustBytes()
		svc := &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{
					ID:        headerID,
					BaseID:    baseID,
					DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt: time.Now().UTC(),
					UpdatedAt: time.Now().UTC(),
				}}, nil
			},
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
				return []dutyroster.BaseView{{ID: baseID, BaseName: "B"}}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
				return []dutyroster.CrewView{{RosterID: headerID, RoleCode: "rescuer", Role: "rescuer"}}, nil
			},
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?page=0&size=-1&draw=7", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}

		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?limit=101", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}

		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?page=99&limit=20", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}

		svc = &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) { return nil, errors.New("headers") },
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}

		svc = &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{}, nil
			},
			listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) { return nil, errors.New("bases") },
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}

		headerID = uuidv7.MustBytes()
		svc = &hemsDutyRosterServiceMock{
			listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
				return []dutyroster.HeaderView{{ID: headerID, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}}, nil
			},
			listHEMSBasesFn:      func(_ []byte) ([]dutyroster.BaseView, error) { return []dutyroster.BaseView{}, nil },
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("crews") },
		}
		h = NewDutyRosterHandler(svc)
		app = newHemsDutyRosterTestApp(h)
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})
}

func TestRespondByID_MoreBranches(t *testing.T) {
	t.Run("not found", func(t *testing.T) {
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) { return nil, nil },
		})
		app := fiber.New()
		app.Get("/x/:id", func(c *fiber.Ctx) error { return h.respondByID(c, http.StatusOK, uuidv7.MustBytes()) })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/1", nil)
		if resp.StatusCode != http.StatusNotFound {
			t.Fatalf("expected 404, got %d", resp.StatusCode)
		}
	})

	t.Run("crew fetch error", func(t *testing.T) {
		id := uuidv7.MustBytes()
		h := NewDutyRosterHandler(&hemsDutyRosterServiceMock{
			getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
				return &dutyroster.HeaderView{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
			},
			getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) { return nil, errors.New("x") },
		})
		app := fiber.New()
		app.Get("/x/:id", func(c *fiber.Ctx) error { return h.respondByID(c, http.StatusOK, id) })
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/x/1", nil)
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("expected 400, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterResource_AllRoleBuckets(t *testing.T) {
	dStart := time.Date(2026, 3, 19, 0, 0, 0, 0, time.UTC)
	dEnd := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
	header := dutyroster.HeaderView{
		ID:        uuidv7.MustBytes(),
		BaseID:    uuidv7.MustBytes(),
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		CreatedAt: time.Now().UTC(),
		UpdatedAt: time.Now().UTC(),
	}
	crews := []dutyroster.CrewView{
		{
			ID:               uuidv7.MustBytes(),
			RoleCode:         "pilot",
			Role:             "pilot",
			CrewType:         "additional",
			Name:             "Pilot A",
			FlightInstructor: true,
			LineChecker:      true,
			Supervisor:       true,
			Examiner:         true,
			CoPilot:          true,
			DateStart:        &dStart,
			DateEnd:          &dEnd,
		},
		{ID: uuidv7.MustBytes(), RoleCode: "doctor", Role: "doctor"},
		{ID: uuidv7.MustBytes(), RoleCode: "doctor", Role: "doctor"},
		{ID: uuidv7.MustBytes(), RoleCode: "air_rescuer", Role: "rescuer"},
		{ID: uuidv7.MustBytes(), RoleCode: "flight_assistant", Role: "helper"},
		{ID: uuidv7.MustBytes(), RoleCode: "other_person", Role: "other", DateStart: &dStart},
		{ID: uuidv7.MustBytes(), RoleCode: "other_person", Role: "other", DateStart: &dStart},
	}
	out := service.DutyRosterResource(context.Background(), header, crews)
	if len(out.Attributes.Detail.Pilot) != 1 || len(out.Attributes.Detail.Doctor) != 2 || len(out.Attributes.Detail.Rescuer) != 1 || len(out.Attributes.Detail.Helper) != 1 || len(out.Attributes.Detail.OtherPerson) != 2 {
		t.Fatalf("unexpected crew buckets: %#v", out.Attributes.Detail)
	}
	pilot := out.Attributes.Detail.Pilot[0]
	if pilot.FlightInstructor == nil || !*pilot.FlightInstructor {
		t.Fatalf("expected pilot flags, got %#v", pilot)
	}
	if out.Attributes.Detail.Doctor[0].ShiftTime != nil {
		t.Fatalf("expected doctor own shift to be omitted")
	}
	if out.Attributes.Detail.OtherPerson[0].ShiftTime != nil || out.Attributes.Detail.OtherPerson[0].ShiftDate != nil {
		t.Fatalf("expected other_person shift/date omitted")
	}
}

func TestBuildCrewRows_SuccessBranches(t *testing.T) {
	actor := uuidv7.MustBytes()
	id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())
	rows, errObj := service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{
			{
				ID:               id,
				CrewType:         "additional",
				FlightInstructor: true,
				LineChecker:      true,
				Supervisor:       true,
				Examiner:         true,
				CoPilot:          true,
				ShiftDate:        &requestdto.DutyRosterShiftDate{DateStart: "2026-03-20", DateEnd: "2026-03-21"},
			},
		},
		OtherPerson: []requestdto.DutyRosterOtherPersonInput{
			{
				Name: "Guest",
			},
		},
	}, actor)
	if errObj != nil {
		t.Fatalf("unexpected err: %#v", errObj)
	}
	if len(rows) != 2 {
		t.Fatalf("expected 2 rows, got %d", len(rows))
	}
	if !rows[0].FlightInstructor || !rows[0].LineChecker || !rows[0].Supervisor || !rows[0].Examiner || !rows[0].CoPilot {
		t.Fatalf("expected additional pilot flags set")
	}
	if rows[0].DateStart == nil || rows[0].DateEnd == nil {
		t.Fatalf("expected pilot date range set")
	}
	if rows[1].RoleCode != "other_person" || rows[1].DateStart != nil || rows[1].ShiftStart != "" {
		t.Fatalf("expected other_person shift/date cleared, got %#v", rows[1])
	}
}

func TestBuildCrewRows_RescuerAndHelperDefaultMain(t *testing.T) {
	actor := uuidv7.MustBytes()
	id := mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes())

	rows, errObj := service.BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Rescuer: []requestdto.DutyRosterCrewInput{{ID: id}},
		Helper:  []requestdto.DutyRosterCrewInput{{ID: id}},
	}, actor)
	if errObj != nil {
		t.Fatalf("unexpected error: %#v", errObj)
	}
	for i := range rows {
		if rows[i].RoleCode != "rescuer" && rows[i].RoleCode != "helper" {
			continue
		}
		if rows[i].CrewType != "main" {
			t.Fatalf("expected non-pilot crew_type main, got %q", rows[i].CrewType)
		}
	}
}

func TestDutyRosterDuplicateDetail(t *testing.T) {
	t.Run("known roster key", func(t *testing.T) {
		got := dutyRosterDuplicateDetail("Error 1062 (23000): Duplicate entry 'x' for key 'idx_roster_base_date'")
		if !strings.Contains(got, "selected base and duty date") {
			t.Fatalf("unexpected detail: %s", got)
		}
	})

	t.Run("known crew key with table prefix", func(t *testing.T) {
		got := dutyRosterDuplicateDetail("Error 1062 (23000): Duplicate entry 'x' for key 'duty_roster_crews.idx_roster_crew_unique'")
		if !strings.Contains(got, "crew assignment already exists") {
			t.Fatalf("unexpected detail: %s", got)
		}
	})

	t.Run("unknown key fallback", func(t *testing.T) {
		got := dutyRosterDuplicateDetail("Error 1062 (23000): Duplicate entry 'x' for key 'uniq_anything'")
		if !strings.Contains(got, "data conflict") {
			t.Fatalf("unexpected detail: %s", got)
		}
	})
}

func TestDutyRosterHandler_BaseEndpointsCoverage(t *testing.T) {
	baseID := uuidv7.MustBytes()
	rosterID := uuidv7.MustBytes()
	baseIDStr := mustUUIDStringHemsDutyRoster(t, baseID)
	rosterIDStr := mustUUIDStringHemsDutyRoster(t, rosterID)
	svc := &hemsDutyRosterServiceMock{
		getBaseShiftFn: func(_ []byte, _ string) (string, error) { return "06:00-21:00", nil },
		getHeaderByIDFn: func(_ []byte) (*dutyroster.HeaderView, error) {
			return &dutyroster.HeaderView{
				ID:               rosterID,
				BaseID:           baseID,
				BaseName:         "Base Alpha",
				BaseAbbreviation: "BA",
				DutyDate:         time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
				CreatedAt:        time.Now().UTC(),
				UpdatedAt:        time.Now().UTC(),
			}, nil
		},
		getCrewsByRosterIDFn: func(_ [][]byte) ([]dutyroster.CrewView, error) {
			return []dutyroster.CrewView{
				{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot Base", RoleCode: "pilot", Role: "pilot", CrewType: "main"},
			}, nil
		},
		listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
			return []dutyroster.HeaderView{
				{
					ID:               rosterID,
					BaseID:           baseID,
					BaseName:         "Base Alpha",
					BaseAbbreviation: "BA",
					DutyDate:         time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt:        time.Now().UTC(),
					UpdatedAt:        time.Now().UTC(),
				},
			}, nil
		},
		listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
			return []dutyroster.BaseView{{ID: baseID, BaseName: "Base Alpha", BaseAbbreviation: "BA"}}, nil
		},
		deleteCrewsFn: func(_ context.Context, _ []byte, _ []dutyroster.DutyRosterCrew, _ []byte) ([]dutyroster.DeletedCrewResult, error) {
			return []dutyroster.DeletedCrewResult{
				{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot Base", RoleCode: "pilot", CrewType: "main"},
			}, nil
		},
	}

	h := NewDutyRosterHandler(svc)
	app := newDutyRosterFullTestApp(h)

	t.Run("create base", func(t *testing.T) {
		body := []byte(`{
			"data": {
				"type": "duty_roster_create",
				"attributes": {
					"base_id": "` + baseIDStr + `",
					"duty_date": "2026-03-20",
					"roster_detail": {"pilot":[],"doctor":[],"rescuer":[],"helper":[],"other_person":[]}
				}
			}
		}`)
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodPost, "/api/v1/base-roster/create", body)
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("expected 201, got %d", resp.StatusCode)
		}
	})

	t.Run("get base", func(t *testing.T) {
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get/"+rosterIDStr, nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("delete base", func(t *testing.T) {
		resp, _ := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/base-roster/delete/"+rosterIDStr, nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("delete base crew", func(t *testing.T) {
		body := []byte(`{"data":{"type":"duty_roster_crew_delete","attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUIDStringHemsDutyRoster(t, uuidv7.MustBytes()) + `","shift_date":{"date_start":"2026-03-20","date_end":"2026-03-20"}}]}}}}`)
		resp, respBody := doJSONRequestHemsDutyRoster(t, app, http.MethodDelete, "/api/v1/base-roster/"+rosterIDStr+"/crew", body)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d body=%s", resp.StatusCode, string(respBody))
		}
		if !strings.Contains(string(respBody), `"name":"Pilot Base"`) {
			t.Fatalf("expected deleted crew name in response, got %s", string(respBody))
		}
	})

	t.Run("list base and dt", func(t *testing.T) {
		resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all?from=2026-03-01&to=2026-03-31&base_name=alpha", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
		if !strings.Contains(strings.ToLower(string(body)), "base alpha") {
			t.Fatalf("expected filtered base name in response, got %s", string(body))
		}
		resp, _ = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/base-roster/get-all/dt?from=2026-03-01&to=2026-03-31&page=1&size=10&base_name=alpha", nil)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})
}

func TestHemsDutyRosterHandlerList_FilterByBaseName(t *testing.T) {
	baseAlpha := uuidv7.MustBytes()
	baseZulu := uuidv7.MustBytes()
	rosterID := uuidv7.MustBytes()
	svc := &hemsDutyRosterServiceMock{
		listHeadersByRangeFn: func(_ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
			return []dutyroster.HeaderView{
				{
					ID:               rosterID,
					BaseID:           baseAlpha,
					BaseName:         "Alpha HEMS Base",
					BaseAbbreviation: "A1",
					DutyDate:         time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
					CreatedAt:        time.Now().UTC(),
					UpdatedAt:        time.Now().UTC(),
				},
			}, nil
		},
		listHEMSBasesFn: func(_ []byte) ([]dutyroster.BaseView, error) {
			return []dutyroster.BaseView{
				{ID: baseAlpha, BaseName: "Alpha HEMS Base", BaseAbbreviation: "A1"},
				{ID: baseZulu, BaseName: "Zulu HEMS Base", BaseAbbreviation: "Z1"},
			}, nil
		},
	}
	h := NewDutyRosterHandler(svc)
	app := newDutyRosterFullTestApp(h)

	resp, body := doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all?from=2026-03-01&to=2026-03-31&base_name=zulu", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	lower := strings.ToLower(string(body))
	if !strings.Contains(lower, "zulu hems base") {
		t.Fatalf("expected zulu base in response, got %s", string(body))
	}
	if strings.Contains(lower, "alpha hems base") {
		t.Fatalf("expected alpha base excluded by filter, got %s", string(body))
	}

	resp, body = doJSONRequestHemsDutyRoster(t, app, http.MethodGet, "/api/v1/hems-roster/get-all/dt?from=2026-03-01&to=2026-03-31&base_name=zulu&page=1&size=10", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("expected 200, got %d", resp.StatusCode)
	}
	lower = strings.ToLower(string(body))
	if !strings.Contains(lower, "zulu hems base") {
		t.Fatalf("expected zulu base in dt response, got %s", string(body))
	}
}
