package service

import (
	"context"
	"errors"
	"strings"
	"testing"
	"time"

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

type hemsDutyRosterRepoMock struct {
	createCalled bool
	updateCalled bool
	deleteCalled bool
	getCalled    bool
	crewsCalled  bool
	listCalled   bool
	basesCalled  bool
	shiftCalled  bool

	lastCreateRow   *dutyroster.DutyRoster
	lastCreateCrews []dutyroster.DutyRosterCrew
	lastUpdateRow   *dutyroster.DutyRoster
	lastUpdateCrews []dutyroster.DutyRosterCrew
	lastDeleteID    []byte
	lastDeleteBy    []byte
	lastGetID       []byte
	lastRosterIDs   [][]byte
	lastListBaseID  []byte
	lastListFrom    time.Time
	lastListTo      time.Time
	lastBasesID     []byte
	lastShiftBaseID []byte
	lastShiftType   string
	lastHeaderType  string
	lastListType    string
	lastBasesType   string
	insertedCrews   []dutyroster.DutyRosterCrew

	headerResult  *dutyroster.HeaderView
	crewsResult   []dutyroster.CrewView
	headersResult []dutyroster.HeaderView
	basesResult   []dutyroster.BaseView
	shiftResult   string

	createErr error
	updateErr error
	deleteErr error
	getErr    error
	crewsErr  error
	listErr   error
	basesErr  error
	shiftErr  error
}

func (m *hemsDutyRosterRepoMock) InTx(_ context.Context, fn func(txRepo dutyroster.TxRepository) error) error {
	return fn(m)
}

func (m *hemsDutyRosterRepoMock) CreateHeader(_ context.Context, row *dutyroster.DutyRoster) error {
	return m.Create(context.Background(), row, nil)
}

func (m *hemsDutyRosterRepoMock) UpdateHeader(_ context.Context, row *dutyroster.DutyRoster) error {
	return m.Update(context.Background(), row, nil)
}

func (m *hemsDutyRosterRepoMock) ListActiveCrewsByRosterID(_ context.Context, _ []byte) ([]dutyroster.DutyRosterCrew, error) {
	return []dutyroster.DutyRosterCrew{}, nil
}

func (m *hemsDutyRosterRepoMock) InsertCrew(_ context.Context, row *dutyroster.DutyRosterCrew) error {
	if row != nil {
		m.insertedCrews = append(m.insertedCrews, *row)
	}
	return nil
}

func (m *hemsDutyRosterRepoMock) CrewExists(_ context.Context, _ dutyroster.DutyRosterCrew) (bool, error) {
	return false, nil
}

func (m *hemsDutyRosterRepoMock) SoftDeleteCrewByUser(_ context.Context, _ []byte, _, _ string, _ []byte, _ []byte) error {
	return nil
}

func (m *hemsDutyRosterRepoMock) FindRosterIDByBaseDate(_ context.Context, _ []byte, _ time.Time) ([]byte, error) {
	return nil, nil
}

func (m *hemsDutyRosterRepoMock) FindRosterIDsByBaseDate(_ context.Context, _ []byte, _ time.Time) ([][]byte, error) {
	return [][]byte{}, nil
}

func (m *hemsDutyRosterRepoMock) MatchCrewByFilter(_ context.Context, _ [][]byte, _ dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) {
	return []dutyroster.DeletedCrewResult{}, nil
}

func (m *hemsDutyRosterRepoMock) SoftDeleteCrewRows(_ context.Context, _ []dutyroster.DeletedCrewResult, _ []byte) error {
	return nil
}

func (m *hemsDutyRosterRepoMock) SoftDeleteCrewForDate(_ context.Context, _ []byte, _ time.Time, _ dutyroster.DutyRosterCrew, _ []byte) error {
	return nil
}

func (m *hemsDutyRosterRepoMock) HardDeleteHeader(_ context.Context, id []byte) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	return m.deleteErr
}

func (m *hemsDutyRosterRepoMock) HardDeleteCrewsByRosterID(_ context.Context, id []byte) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	return m.deleteErr
}

func (m *hemsDutyRosterRepoMock) GetRosterByID(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) {
	m.getCalled = true
	m.lastGetID = id
	if m.getErr != nil {
		return nil, m.getErr
	}
	return &dutyroster.DutyRoster{
		ID:       id,
		BaseID:   m.lastBasesID,
		DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
	}, nil
}

func (m *hemsDutyRosterRepoMock) LockRosterByID(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) {
	return m.GetRosterByID(context.Background(), id)
}

func (m *hemsDutyRosterRepoMock) SoftDeleteHeader(_ context.Context, id []byte, deletedBy []byte) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	return m.deleteErr
}

func (m *hemsDutyRosterRepoMock) SoftDeleteCrewsByRosterID(_ context.Context, id []byte, deletedBy []byte) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	return m.deleteErr
}

func (m *hemsDutyRosterRepoMock) Create(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
	m.createCalled = true
	m.lastCreateRow = row
	m.lastCreateCrews = crews
	return m.createErr
}

func (m *hemsDutyRosterRepoMock) Update(_ context.Context, row *dutyroster.DutyRoster, crews []dutyroster.DutyRosterCrew) error {
	m.updateCalled = true
	m.lastUpdateRow = row
	m.lastUpdateCrews = crews
	return m.updateErr
}

func (m *hemsDutyRosterRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte) error {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	return m.deleteErr
}

func (m *hemsDutyRosterRepoMock) DeleteCrews(_ context.Context, id []byte, _ []dutyroster.DutyRosterCrew, deletedBy []byte) ([]dutyroster.DeletedCrewResult, error) {
	m.deleteCalled = true
	m.lastDeleteID = id
	m.lastDeleteBy = deletedBy
	return []dutyroster.DeletedCrewResult{}, m.deleteErr
}

func (m *hemsDutyRosterRepoMock) GetBaseDefaultShift(_ context.Context, baseID []byte, baseType string) (string, error) {
	m.shiftCalled = true
	m.lastShiftBaseID = baseID
	m.lastShiftType = baseType
	return m.shiftResult, m.shiftErr
}

func (m *hemsDutyRosterRepoMock) GetHeaderByID(_ context.Context, id []byte) (*dutyroster.HeaderView, error) {
	m.getCalled = true
	m.lastGetID = id
	return m.headerResult, m.getErr
}

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

func (m *hemsDutyRosterRepoMock) GetCrewsByRosterIDs(_ context.Context, rosterIDs [][]byte) ([]dutyroster.CrewView, error) {
	m.crewsCalled = true
	m.lastRosterIDs = rosterIDs
	return m.crewsResult, m.crewsErr
}

func (m *hemsDutyRosterRepoMock) ListHeadersByRange(_ context.Context, baseID []byte, from, to time.Time) ([]dutyroster.HeaderView, error) {
	m.listCalled = true
	m.lastListBaseID = baseID
	m.lastListFrom = from
	m.lastListTo = to
	return m.headersResult, m.listErr
}

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

func (m *hemsDutyRosterRepoMock) ListHEMSBases(_ context.Context, baseID []byte) ([]dutyroster.BaseView, error) {
	m.basesCalled = true
	m.lastBasesID = baseID
	return m.basesResult, m.basesErr
}

func (m *hemsDutyRosterRepoMock) ListBasesByType(_ context.Context, baseID []byte, baseType string) ([]dutyroster.BaseView, error) {
	m.lastBasesType = baseType
	m.basesCalled = true
	m.lastBasesID = baseID
	return m.basesResult, m.basesErr
}

func TestHemsDutyRosterService_ForwardsAllMethods(t *testing.T) {
	repo := &hemsDutyRosterRepoMock{
		headerResult: &dutyroster.HeaderView{BaseName: "Base A"},
		crewsResult:  []dutyroster.CrewView{{RoleCode: "pilot"}},
		headersResult: []dutyroster.HeaderView{{
			BaseName: "Base A",
		}},
		basesResult: []dutyroster.BaseView{{BaseName: "Base A"}},
	}
	svc := NewDutyRosterService(repo)

	row := &dutyroster.DutyRoster{}
	crews := []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}}
	id := []byte("roster-id-123456")
	deletedBy := []byte("actor-id-1234567")
	baseID := []byte("base-id-12345678")
	from := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
	to := time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC)

	if err := svc.Create(context.Background(), row, crews); err != nil {
		t.Fatalf("create: %v", err)
	}
	if !repo.createCalled || repo.lastCreateRow != row {
		t.Fatalf("expected create forwarded")
	}

	if err := svc.Update(context.Background(), row, crews); err != nil {
		t.Fatalf("update: %v", err)
	}
	if !repo.updateCalled || repo.lastUpdateRow != row {
		t.Fatalf("expected update forwarded")
	}

	if err := svc.Delete(context.Background(), id, deletedBy); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if !repo.deleteCalled || string(repo.lastDeleteID) != string(id) || string(repo.lastDeleteBy) != string(deletedBy) {
		t.Fatalf("expected delete forwarded")
	}

	if _, err := svc.GetBaseDefaultShift(context.Background(), baseID, "hems"); err != nil {
		t.Fatalf("get base default shift: %v", err)
	}
	if !repo.shiftCalled || string(repo.lastShiftBaseID) != string(baseID) || repo.lastShiftType != "hems" {
		t.Fatalf("expected get base default shift forwarded")
	}

	gotHeader, err := svc.GetHeaderByID(context.Background(), id)
	if err != nil || gotHeader == nil || gotHeader.BaseName != "Base A" {
		t.Fatalf("expected get header forwarded")
	}
	if !repo.getCalled || string(repo.lastGetID) != string(id) {
		t.Fatalf("expected get header args forwarded")
	}
	if _, err := svc.GetHeaderByIDByBaseType(context.Background(), id, "base"); err != nil {
		t.Fatalf("expected get header by base type forwarded")
	}
	if repo.lastHeaderType == "" {
		t.Fatalf("expected base type get header path called")
	}

	gotCrews, err := svc.GetCrewsByRosterIDs(context.Background(), [][]byte{id})
	if err != nil || len(gotCrews) != 1 {
		t.Fatalf("expected get crews forwarded")
	}
	if !repo.crewsCalled || len(repo.lastRosterIDs) != 1 {
		t.Fatalf("expected get crews args forwarded")
	}

	gotHeaders, err := svc.ListHeadersByRange(context.Background(), baseID, from, to)
	if err != nil || len(gotHeaders) != 1 {
		t.Fatalf("expected list headers forwarded")
	}
	if !repo.listCalled || string(repo.lastListBaseID) != string(baseID) || !repo.lastListFrom.Equal(from) || !repo.lastListTo.Equal(to) {
		t.Fatalf("expected list headers args forwarded")
	}
	if _, err := svc.ListHeadersByRangeByBaseType(context.Background(), baseID, from, to, "base"); err != nil {
		t.Fatalf("expected list headers by base type forwarded")
	}
	if repo.lastListType != "base" {
		t.Fatalf("expected base type forwarded to list headers, got %q", repo.lastListType)
	}

	gotBases, err := svc.ListHEMSBases(context.Background(), baseID)
	if err != nil || len(gotBases) != 1 {
		t.Fatalf("expected list bases forwarded")
	}
	if !repo.basesCalled || string(repo.lastBasesID) != string(baseID) {
		t.Fatalf("expected list bases args forwarded")
	}
	if _, err := svc.ListBasesByType(context.Background(), baseID, "base"); err != nil {
		t.Fatalf("expected list bases by type forwarded")
	}
	if repo.lastBasesType != "base" {
		t.Fatalf("expected base type forwarded to list bases, got %q", repo.lastBasesType)
	}
}

func TestHemsDutyRosterService_PropagatesErrors(t *testing.T) {
	repo := &hemsDutyRosterRepoMock{
		createErr: errors.New("create error"),
		updateErr: errors.New("update error"),
		deleteErr: errors.New("delete error"),
		getErr:    errors.New("get error"),
		crewsErr:  errors.New("crews error"),
		listErr:   errors.New("list error"),
		basesErr:  errors.New("bases error"),
	}
	svc := NewDutyRosterService(repo)

	if err := svc.Create(context.Background(), &dutyroster.DutyRoster{}, nil); err == nil {
		t.Fatalf("expected create error")
	}
	if err := svc.Update(context.Background(), &dutyroster.DutyRoster{}, nil); err == nil {
		t.Fatalf("expected update error")
	}
	if err := svc.Delete(context.Background(), []byte("id"), []byte("actor")); err == nil {
		t.Fatalf("expected delete error")
	}
	if _, err := svc.GetHeaderByID(context.Background(), []byte("id")); err == nil {
		t.Fatalf("expected get error")
	}
	if _, err := svc.GetCrewsByRosterIDs(context.Background(), [][]byte{[]byte("id")}); err == nil {
		t.Fatalf("expected crews error")
	}
	if _, err := svc.ListHeadersByRange(context.Background(), []byte("base"), time.Now(), time.Now()); err == nil {
		t.Fatalf("expected list error")
	}
	if _, err := svc.ListHEMSBases(context.Background(), []byte("base")); err == nil {
		t.Fatalf("expected bases error")
	}
}

func TestHemsDutyRosterService_NormalizesWritePayloadBeforeRepo(t *testing.T) {
	repo := &hemsDutyRosterRepoMock{}
	svc := NewDutyRosterService(repo)

	row := &dutyroster.DutyRoster{}
	crews := []dutyroster.DutyRosterCrew{
		{
			RoleCode: "pilot",
			CrewType: "main",
		},
	}

	if err := svc.Create(context.Background(), row, crews); err != nil {
		t.Fatalf("create: %v", err)
	}
	if repo.lastCreateRow == nil {
		t.Fatalf("expected create row forwarded")
	}
	if len(repo.insertedCrews) != 1 {
		t.Fatalf("expected 1 crew inserted")
	}
	if repo.insertedCrews[0].ShiftStart != "2000-01-01 00:00:00" || repo.insertedCrews[0].ShiftEnd != "2000-01-01 00:00:00" {
		t.Fatalf("expected crew shift normalized, got %q-%q", repo.insertedCrews[0].ShiftStart, repo.insertedCrews[0].ShiftEnd)
	}
}

func TestHemsDutyRosterService_NormalizeFilterShiftInput(t *testing.T) {
	if got := normalizeCrewShiftForFilter(""); got != "" {
		t.Fatalf("expected empty filter shift, got %q", got)
	}
	if got := normalizeCrewShiftForFilter("08:15"); got != "2000-01-01 08:15:00" {
		t.Fatalf("expected normalized filter shift, got %q", got)
	}
}

func TestDutyRosterDeletedCrewItems_IncludesName(t *testing.T) {
	rows := []dutyroster.DeletedCrewResult{
		{
			ID:       []byte("1234567890123456"),
			RosterID: []byte("abcdefabcdefabcd"),
			UserID:   []byte("9999999999999999"),
			Name:     "Pilot One",
			RoleCode: "pilot",
			CrewType: "main",
		},
	}

	items := DutyRosterDeletedCrewItems(rows)
	if len(items) != 1 {
		t.Fatalf("expected 1 item, got %d", len(items))
	}
	if got, _ := items[0]["name"].(string); got != "Pilot One" {
		t.Fatalf("expected deleted item name, got %#v", items[0]["name"])
	}
}

type dutyRosterTxRepoMock struct {
	lockFn              func(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error)
	createHeaderFn      func(ctx context.Context, row *dutyroster.DutyRoster) error
	findIDByDateFn      func(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error)
	listActiveCrewsFn   func(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error)
	insertCrewFn        func(ctx context.Context, row *dutyroster.DutyRosterCrew) error
	crewExistsFn        func(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error)
	softDeleteByUserFn  func(ctx context.Context, rosterID []byte, roleCode, crewType string, userID []byte, deletedBy []byte) error
	softDeleteCrewsFn   func(ctx context.Context, rosterID []byte, deletedBy []byte) error
	softDeleteForDateFn func(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, deletedBy []byte) error
	findIDsByDateFn     func(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error)
	matchCrewByFilterFn func(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error)
	softDeleteRowsFn    func(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error
}

func (m *dutyRosterTxRepoMock) GetRosterByID(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
	return nil, nil
}
func (m *dutyRosterTxRepoMock) LockRosterByID(ctx context.Context, id []byte) (*dutyroster.DutyRoster, error) {
	if m.lockFn != nil {
		return m.lockFn(ctx, id)
	}
	return &dutyroster.DutyRoster{ID: id, BaseID: uuidv7.MustBytes(), DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
}
func (m *dutyRosterTxRepoMock) CreateHeader(ctx context.Context, row *dutyroster.DutyRoster) error {
	if m.createHeaderFn != nil {
		return m.createHeaderFn(ctx, row)
	}
	return nil
}
func (m *dutyRosterTxRepoMock) UpdateHeader(_ context.Context, _ *dutyroster.DutyRoster) error {
	return nil
}
func (m *dutyRosterTxRepoMock) SoftDeleteHeader(_ context.Context, _ []byte, _ []byte) error {
	return nil
}
func (m *dutyRosterTxRepoMock) SoftDeleteCrewsByRosterID(ctx context.Context, rosterID []byte, deletedBy []byte) error {
	if m.softDeleteCrewsFn != nil {
		return m.softDeleteCrewsFn(ctx, rosterID, deletedBy)
	}
	return nil
}
func (m *dutyRosterTxRepoMock) ListActiveCrewsByRosterID(ctx context.Context, rosterID []byte) ([]dutyroster.DutyRosterCrew, error) {
	if m.listActiveCrewsFn != nil {
		return m.listActiveCrewsFn(ctx, rosterID)
	}
	return nil, nil
}
func (m *dutyRosterTxRepoMock) InsertCrew(ctx context.Context, row *dutyroster.DutyRosterCrew) error {
	if m.insertCrewFn != nil {
		return m.insertCrewFn(ctx, row)
	}
	return nil
}
func (m *dutyRosterTxRepoMock) CrewExists(ctx context.Context, row dutyroster.DutyRosterCrew) (bool, error) {
	if m.crewExistsFn != nil {
		return m.crewExistsFn(ctx, row)
	}
	return false, nil
}
func (m *dutyRosterTxRepoMock) SoftDeleteCrewByUser(ctx context.Context, rosterID []byte, roleCode, crewType string, userID []byte, deletedBy []byte) error {
	if m.softDeleteByUserFn != nil {
		return m.softDeleteByUserFn(ctx, rosterID, roleCode, crewType, userID, deletedBy)
	}
	return nil
}
func (m *dutyRosterTxRepoMock) FindRosterIDByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([]byte, error) {
	if m.findIDByDateFn != nil {
		return m.findIDByDateFn(ctx, baseID, dutyDate)
	}
	return nil, nil
}
func (m *dutyRosterTxRepoMock) FindRosterIDsByBaseDate(ctx context.Context, baseID []byte, dutyDate time.Time) ([][]byte, error) {
	if m.findIDsByDateFn != nil {
		return m.findIDsByDateFn(ctx, baseID, dutyDate)
	}
	return [][]byte{uuidv7.MustBytes()}, nil
}
func (m *dutyRosterTxRepoMock) MatchCrewByFilter(ctx context.Context, rosterIDs [][]byte, crew dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) {
	if m.matchCrewByFilterFn != nil {
		return m.matchCrewByFilterFn(ctx, rosterIDs, crew)
	}
	return []dutyroster.DeletedCrewResult{}, nil
}
func (m *dutyRosterTxRepoMock) SoftDeleteCrewRows(ctx context.Context, rows []dutyroster.DeletedCrewResult, deletedBy []byte) error {
	if m.softDeleteRowsFn != nil {
		return m.softDeleteRowsFn(ctx, rows, deletedBy)
	}
	return nil
}
func (m *dutyRosterTxRepoMock) SoftDeleteCrewForDate(ctx context.Context, baseID []byte, dutyDate time.Time, crew dutyroster.DutyRosterCrew, deletedBy []byte) error {
	if m.softDeleteForDateFn != nil {
		return m.softDeleteForDateFn(ctx, baseID, dutyDate, crew, deletedBy)
	}
	return nil
}

func (m *dutyRosterTxRepoMock) HardDeleteHeader(_ context.Context, _ []byte) error {
	return nil
}

func (m *dutyRosterTxRepoMock) HardDeleteCrewsByRosterID(_ context.Context, _ []byte) error {
	return nil
}

type dutyRosterRepoWithTxMock struct {
	txRepo      dutyroster.TxRepository
	inTxErr     error
	shiftResult string
	shiftErr    error
}

func (m *dutyRosterRepoWithTxMock) InTx(ctx context.Context, fn func(txRepo dutyroster.TxRepository) error) error {
	if m.inTxErr != nil {
		return m.inTxErr
	}
	return fn(m.txRepo)
}
func (m *dutyRosterRepoWithTxMock) GetBaseDefaultShift(_ context.Context, _ []byte, _ string) (string, error) {
	return m.shiftResult, m.shiftErr
}
func (m *dutyRosterRepoWithTxMock) GetHeaderByID(_ context.Context, _ []byte) (*dutyroster.HeaderView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) GetHeaderByIDByBaseType(_ context.Context, _ []byte, _ string) (*dutyroster.HeaderView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) GetCrewsByRosterIDs(_ context.Context, _ [][]byte) ([]dutyroster.CrewView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) ListHeadersByRange(_ context.Context, _ []byte, _, _ time.Time) ([]dutyroster.HeaderView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) ListHeadersByRangeByBaseType(_ context.Context, _ []byte, _, _ time.Time, _ string) ([]dutyroster.HeaderView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) ListHEMSBases(_ context.Context, _ []byte) ([]dutyroster.BaseView, error) {
	return nil, nil
}
func (m *dutyRosterRepoWithTxMock) ListBasesByType(_ context.Context, _ []byte, _ string) ([]dutyroster.BaseView, error) {
	return nil, nil
}

func TestDutyRosterDeleteCrewsFlow(t *testing.T) {
	baseID := uuidv7.MustBytes()
	rosterID := uuidv7.MustBytes()
	deletedBy := uuidv7.MustBytes()
	rowA := dutyroster.DeletedCrewResult{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot A", RoleCode: "pilot", CrewType: "main"}
	rowB := dutyroster.DeletedCrewResult{ID: uuidv7.MustBytes(), RosterID: rosterID, UserID: uuidv7.MustBytes(), Name: "Pilot B", RoleCode: "pilot", CrewType: "main"}

	deleteCalls := 0
	tx := &dutyRosterTxRepoMock{
		lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
			return &dutyroster.DutyRoster{ID: rosterID, BaseID: baseID, DutyDate: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)}, nil
		},
		findIDsByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([][]byte, error) {
			return [][]byte{rosterID}, nil
		},
		matchCrewByFilterFn: func(_ context.Context, _ [][]byte, _ dutyroster.DutyRosterCrew) ([]dutyroster.DeletedCrewResult, error) {
			// include duplicate ID to cover de-dup collector.
			return []dutyroster.DeletedCrewResult{rowA, rowA, rowB}, nil
		},
		softDeleteRowsFn: func(_ context.Context, rows []dutyroster.DeletedCrewResult, _ []byte) error {
			deleteCalls++
			if len(rows) == 0 {
				t.Fatalf("expected rows for delete")
			}
			return nil
		},
	}
	repo := &dutyRosterRepoWithTxMock{txRepo: tx}
	svc := NewDutyRosterService(repo)

	start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
	end := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
	crews := []dutyroster.DutyRosterCrew{
		{
			RoleCode:  "pilot",
			CrewType:  "main",
			UserID:    rowA.UserID,
			DateStart: &start,
			DateEnd:   &end,
		},
	}
	out, err := svc.DeleteCrews(context.Background(), rosterID, crews, deletedBy)
	if err != nil {
		t.Fatalf("delete crews: %v", err)
	}
	if deleteCalls == 0 {
		t.Fatalf("expected soft delete called")
	}
	if len(out) != 2 {
		t.Fatalf("expected de-duplicated deleted rows = 2, got %d", len(out))
	}
}

func TestDutyRosterDeleteCrews_Errors(t *testing.T) {
	rosterID := uuidv7.MustBytes()
	tx := &dutyRosterTxRepoMock{
		lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
			return nil, errors.New("lock failed")
		},
	}
	repo := &dutyRosterRepoWithTxMock{txRepo: tx}
	svc := NewDutyRosterService(repo)

	_, err := svc.DeleteCrews(context.Background(), rosterID, []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}}, uuidv7.MustBytes())
	if err == nil {
		t.Fatalf("expected error")
	}
}

func TestDutyRosterResolveBaseDefaultShiftRangeBranches(t *testing.T) {
	svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}})
	start, end, err := svc.ResolveBaseDefaultShiftRange(context.Background(), nil, "base")
	if err != nil || start != "" || end != "" {
		t.Fatalf("expected empty fallback shift, got %q-%q err=%v", start, end, err)
	}

	repoErr := &dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, shiftErr: errors.New("db error")}
	svcErr := NewDutyRosterService(repoErr)
	_, _, err = svcErr.ResolveBaseDefaultShiftRange(context.Background(), uuidv7.MustBytes(), "base")
	if err == nil {
		t.Fatalf("expected error from repo")
	}

	repoRaw := &dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, shiftResult: "start=07:00 end=19:00"}
	svcRaw := NewDutyRosterService(repoRaw)
	start, end, err = svcRaw.ResolveBaseDefaultShiftRange(context.Background(), uuidv7.MustBytes(), "base")
	if err != nil || start != "07:00:00" || end != "19:00:00" {
		t.Fatalf("expected parsed shift, got %q-%q err=%v", start, end, err)
	}
}

func TestDutyRosterParsingHelpers(t *testing.T) {
	// ParseDutyRosterShiftRange + token extraction
	s, e := ParseDutyRosterShiftRange("Shift 08:15 - 20:45")
	if s != "08:15:00" || e != "20:45:00" {
		t.Fatalf("unexpected parsed range: %q-%q", s, e)
	}
	s, e = ParseDutyRosterShiftRange("bad")
	if s != "" || e != "" {
		t.Fatalf("expected empty range on bad input")
	}

	// NormalizeDutyRosterClockInput
	out, errObj := NormalizeDutyRosterClockInput("09:30", "", "/x")
	if errObj != nil || out != "09:30:00" {
		t.Fatalf("expected normalized HH:MM, got out=%q err=%#v", out, errObj)
	}
	_, errObj = NormalizeDutyRosterClockInput("99:99", "", "/x")
	if errObj == nil {
		t.Fatalf("expected invalid time error")
	}

	// ParseDutyRosterDateRangeOrDefault
	now := time.Date(2026, 3, 18, 10, 0, 0, 0, time.UTC)
	from, to, errObj := ParseDutyRosterDateRangeOrDefault("", "", now)
	if errObj != nil || from.Format("2006-01-02") != "2026-03-01" || to.Format("2006-01-02") != "2026-03-31" {
		t.Fatalf("unexpected default range: %v %v err=%#v", from, to, errObj)
	}
	_, _, errObj = ParseDutyRosterDateRangeOrDefault("bad", "", now)
	if errObj == nil || errObj.Pointer != "/query/from" {
		t.Fatalf("expected invalid from")
	}
	_, _, errObj = ParseDutyRosterDateRangeOrDefault("", "bad", now)
	if errObj == nil || errObj.Pointer != "/query/to" {
		t.Fatalf("expected invalid to")
	}

	if _, err := ParseDutyRosterDateOnly("2026-03-20"); err != nil {
		t.Fatalf("ParseDutyRosterDateOnly: %v", err)
	}
}

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

	crews, errObj := BuildDutyRosterDeleteCrews([]byte("{}"), actor)
	if errObj != nil || crews != nil {
		t.Fatalf("expected nil crews on empty payload")
	}

	_, errObj = BuildDutyRosterDeleteCrews([]byte("{"), actor)
	if errObj == nil || errObj.Pointer != "/data" {
		t.Fatalf("expected invalid delete payload")
	}

	body := []byte(`{"data":{"attributes":{"roster_detail":{"pilot":[{"id":"` + mustUUID(t) + `","shift_date":{"date_start":"2026-03-21"}}]}}}}`)
	_, errObj = BuildDutyRosterDeleteCrews(body, actor)
	if errObj == nil || errObj.Detail != "shift_date.date_start and shift_date.date_end must be provided together" {
		t.Fatalf("expected shift_date pair validation, got %#v", errObj)
	}

	body = []byte(`{"data":{"attributes":{"roster_detail":{"other_person":[{"name":"Guest"}]}}}}`)
	crews, errObj = BuildDutyRosterDeleteCrews(body, actor)
	if errObj != nil || len(crews) != 1 {
		t.Fatalf("expected valid other_person delete crew, got crews=%d err=%#v", len(crews), errObj)
	}
}

func TestDutyRosterGroupingAndMetadataHelpers(t *testing.T) {
	rosterID := uuidv7.MustBytes()
	baseID := uuidv7.MustBytes()
	groups := GroupRostersByBase([]responsedto.DutyRosterResource{
		{
			ID: "r1",
			Attributes: responsedto.DutyRosterAttributes{
				Bases: responsedto.DutyRosterBaseInfo{ID: "b1", BaseName: "Base 1"},
			},
		},
		{
			ID: "r2",
			Attributes: responsedto.DutyRosterAttributes{
				Bases: responsedto.DutyRosterBaseInfo{ID: "b1", BaseName: "Base 1"},
			},
		},
	})
	if len(groups) != 1 || len(groups[0].Attributes.Rosters) != 2 {
		t.Fatalf("unexpected grouped rosters: %#v", groups)
	}

	merged := MergeEmptyBaseGroups(groups, []dutyroster.BaseView{{ID: baseID, BaseName: "Base 2"}})
	if len(merged) != 2 {
		t.Fatalf("expected appended empty base group")
	}

	meta := DutyRosterCrewAuditMetadata(rosterID, "hems", []dutyroster.DutyRosterCrew{{RoleCode: "pilot"}})
	if meta["base_type"] != "hems" {
		t.Fatalf("unexpected metadata: %#v", meta)
	}

	if roleBucket("air_rescuer") != "rescuer" || roleBucket("flight_assistant") != "helper" || roleBucket("x") != "other_person" {
		t.Fatalf("unexpected role bucket mapping")
	}
	if firstNonEmpty("a", "b") != "a" || firstNonEmpty("", "b") != "b" {
		t.Fatalf("unexpected firstNonEmpty")
	}
	if p := boolPtrIfTrue(true); p == nil || !*p {
		t.Fatalf("unexpected bool ptr")
	}
	if p := boolPtrIfTrue(false); p != nil {
		t.Fatalf("expected nil ptr for false")
	}
}

func TestDutyRosterResourceMapping(t *testing.T) {
	header := dutyroster.HeaderView{
		ID:               uuidv7.MustBytes(),
		BaseID:           uuidv7.MustBytes(),
		BaseName:         "Base A",
		BaseAbbreviation: "BA",
		DutyDate:         time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		CreatedBy:        uuidv7.MustBytes(),
		CreatedAt:        time.Date(2026, 3, 20, 1, 0, 0, 0, time.UTC),
		UpdatedAt:        time.Date(2026, 3, 20, 2, 0, 0, 0, time.UTC),
	}
	crews := []dutyroster.CrewView{
		{
			ID:               uuidv7.MustBytes(),
			UserID:           uuidv7.MustBytes(),
			Name:             "Pilot One",
			Role:             "pilot",
			RoleCode:         "pilot",
			CrewType:         "additional",
			FlightInstructor: true,
		},
		{
			ID:       uuidv7.MustBytes(),
			Name:     "Guest",
			Role:     "other",
			RoleCode: "other_person",
		},
	}
	got := DutyRosterResource(context.Background(), header, crews)
	if got.Type != "duty_roster" || got.Attributes.Bases.BaseName != "Base A" {
		t.Fatalf("unexpected duty roster resource: %#v", got)
	}
	if len(got.Attributes.Detail.Pilot) != 1 || len(got.Attributes.Detail.OtherPerson) != 1 {
		t.Fatalf("unexpected role buckets: %#v", got.Attributes.Detail)
	}
	if got.Attributes.CreatedBy == "" {
		t.Fatalf("expected created_by on duty roster resource")
	}
}

func TestBuildDutyRosterCrewRows_CoreBranches(t *testing.T) {
	actor := uuidv7.MustBytes()
	pilotID := mustUUID(t)

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

	// success with pilot additional flags + other_person
	rows, errObj := BuildDutyRosterCrewRows(requestdto.DutyRosterDetailsInput{
		Pilot: []requestdto.DutyRosterPilotCrewInput{{
			ID:               pilotID,
			CrewType:         "additional",
			FlightInstructor: true,
		}},
		OtherPerson: []requestdto.DutyRosterOtherPersonInput{{
			Name: "Guest",
		}},
	}, actor)
	if errObj != nil || len(rows) != 2 {
		t.Fatalf("expected success rows, got err=%#v len=%d", errObj, len(rows))
	}
	if !rows[0].FlightInstructor {
		t.Fatalf("expected pilot additional flag set")
	}
	if rows[1].RoleCode != "other_person" || rows[1].DateStart != nil || rows[1].ShiftStart != "" {
		t.Fatalf("expected other_person shift/date stripped")
	}
}

func TestDutyRosterService_EnsureRosterForDateBranches(t *testing.T) {
	svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}})
	source := &dutyroster.DutyRoster{
		BaseID:    uuidv7.MustBytes(),
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		CreatedBy: uuidv7.MustBytes(),
		UpdatedBy: uuidv7.MustBytes(),
	}
	target := time.Date(2026, 3, 21, 10, 0, 0, 0, time.FixedZone("X", 7*3600))

	t.Run("existing roster", func(t *testing.T) {
		existingID := uuidv7.MustBytes()
		locked := 0
		tx := &dutyRosterTxRepoMock{
			findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) {
				return existingID, nil
			},
			lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) {
				locked++
				return &dutyroster.DutyRoster{ID: id}, nil
			},
		}
		got, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err != nil || string(got) != string(existingID) || locked != 1 {
			t.Fatalf("expected existing roster path, got id=%x lock=%d err=%v", got, locked, err)
		}
	})

	t.Run("create header", func(t *testing.T) {
		tx := &dutyRosterTxRepoMock{
			createHeaderFn: func(_ context.Context, row *dutyroster.DutyRoster) error {
				row.ID = uuidv7.MustBytes()
				return nil
			},
		}
		got, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err != nil || len(got) == 0 {
			t.Fatalf("expected created roster id, got %x err=%v", got, err)
		}
	})

	t.Run("concurrent create fallback winner", func(t *testing.T) {
		winner := uuidv7.MustBytes()
		locked := 0
		tx := &dutyRosterTxRepoMock{
			createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") },
			findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return winner, nil },
			lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) {
				locked++
				return &dutyroster.DutyRoster{ID: id}, nil
			},
		}
		got, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err != nil || string(got) != string(winner) || locked != 1 {
			t.Fatalf("expected winner fallback, got id=%x lock=%d err=%v", got, locked, err)
		}
	})

	t.Run("existing roster lock error", func(t *testing.T) {
		existingID := uuidv7.MustBytes()
		tx := &dutyRosterTxRepoMock{
			findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) {
				return existingID, nil
			},
			lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
				return nil, errors.New("lock failed")
			},
		}
		_, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err == nil {
			t.Fatalf("expected lock error")
		}
	})

	t.Run("create error and fetch error", func(t *testing.T) {
		tx := &dutyRosterTxRepoMock{
			createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") },
			findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return nil, errors.New("fetch failed") },
		}
		_, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err == nil || !strings.Contains(err.Error(), "fetch failed") {
			t.Fatalf("expected fetch error after create fail, got %v", err)
		}
	})

	t.Run("create error and no winner id", func(t *testing.T) {
		tx := &dutyRosterTxRepoMock{
			createHeaderFn: func(_ context.Context, _ *dutyroster.DutyRoster) error { return errors.New("duplicate") },
			findIDByDateFn: func(_ context.Context, _ []byte, _ time.Time) ([]byte, error) { return nil, nil },
		}
		_, err := svc.ensureRosterForDate(context.Background(), tx, source, target)
		if err == nil || !strings.Contains(err.Error(), "duplicate") {
			t.Fatalf("expected original create error, got %v", err)
		}
	})
}

func TestDutyRosterService_AddRemoveRangeAssignmentsAndHelpers(t *testing.T) {
	baseID := uuidv7.MustBytes()
	row := &dutyroster.DutyRoster{
		BaseID:    baseID,
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		UpdatedBy: uuidv7.MustBytes(),
		CreatedBy: uuidv7.MustBytes(),
	}
	userID := uuidv7.MustBytes()
	start := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
	end := time.Date(2026, 3, 22, 0, 0, 0, 0, time.UTC)
	crews := []dutyroster.DutyRosterCrew{{
		RoleCode:  "pilot",
		CrewType:  "main",
		UserID:    userID,
		DateStart: &start,
		DateEnd:   &end,
	}}

	t.Run("add range", func(t *testing.T) {
		inserted := 0
		deletedByUser := 0
		callsByDate := map[string]int{}
		existingRoster := uuidv7.MustBytes()
		newRoster := uuidv7.MustBytes()
		tx := &dutyRosterTxRepoMock{
			findIDByDateFn: func(_ context.Context, _ []byte, dutyDate time.Time) ([]byte, error) {
				d := dutyDate.UTC().Format("2006-01-02")
				callsByDate[d]++
				if d == "2026-03-21" {
					return existingRoster, nil
				}
				return nil, nil
			},
			lockFn: func(_ context.Context, id []byte) (*dutyroster.DutyRoster, error) {
				return &dutyroster.DutyRoster{ID: id}, nil
			},
			createHeaderFn: func(_ context.Context, r *dutyroster.DutyRoster) error {
				r.ID = append([]byte(nil), newRoster...)
				return nil
			},
			crewExistsFn: func(_ context.Context, row dutyroster.DutyRosterCrew) (bool, error) {
				// skip insert for existing roster, insert for newly-created roster.
				return string(row.RosterID) == string(existingRoster), nil
			},
			insertCrewFn: func(_ context.Context, _ *dutyroster.DutyRosterCrew) error {
				inserted++
				return nil
			},
			softDeleteByUserFn: func(_ context.Context, _ []byte, _, _ string, _ []byte, _ []byte) error {
				deletedByUser++
				return nil
			},
		}
		svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx})
		if err := svc.addRangeAssignments(context.Background(), tx, row, crews); err != nil {
			t.Fatalf("add range assignments: %v", err)
		}
		if inserted != 1 || deletedByUser != 2 || callsByDate["2026-03-21"] == 0 || callsByDate["2026-03-22"] == 0 {
			t.Fatalf("unexpected add-range effects inserted=%d deletedByUser=%d calls=%v", inserted, deletedByUser, callsByDate)
		}
	})

	t.Run("remove range", func(t *testing.T) {
		deletedDates := []string{}
		tx := &dutyRosterTxRepoMock{
			softDeleteForDateFn: func(_ context.Context, _ []byte, dutyDate time.Time, _ dutyroster.DutyRosterCrew, _ []byte) error {
				deletedDates = append(deletedDates, dutyDate.UTC().Format("2006-01-02"))
				return nil
			},
		}
		svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx})
		newEnd := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
		newCrews := []dutyroster.DutyRosterCrew{{
			RoleCode:  "pilot",
			CrewType:  "main",
			UserID:    userID,
			DateStart: &start,
			DateEnd:   &newEnd,
		}}
		if err := svc.removeRangeAssignments(context.Background(), tx, row, crews, newCrews); err != nil {
			t.Fatalf("remove range assignments: %v", err)
		}
		if len(deletedDates) != 1 || deletedDates[0] != "2026-03-22" {
			t.Fatalf("expected delete only out-of-range day, got %v", deletedDates)
		}
	})

	parsed := collectRangeAssignments([]dutyroster.DutyRosterCrew{
		{RoleCode: "other_person", DateStart: &start, DateEnd: &end},
		{RoleCode: "pilot", DateStart: &end, DateEnd: &start},
		{RoleCode: "pilot", DateStart: &start, DateEnd: &end, UserID: userID, CrewType: "main"},
	})
	if len(parsed) != 1 {
		t.Fatalf("expected one valid range assignment, got %d", len(parsed))
	}
	if assignmentKey(parsed[0].crew) == "" {
		t.Fatalf("expected non-empty assignment key")
	}
	if replaceByUserKey(nil, "pilot", "main", userID) != "" || replaceByUserKey(uuidv7.MustBytes(), "pilot", "main", nil) != "" {
		t.Fatalf("expected empty replace key for missing ids")
	}
	if !isRoleRangeAssignable("pilot") || isRoleRangeAssignable("other_person") {
		t.Fatalf("unexpected range-assignable role mapping")
	}

	if len(expandDateRange(time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC), time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC))) != 0 {
		t.Fatalf("expected empty range when end before start")
	}
}

func TestDutyRosterService_CRUDBranchesWithTx(t *testing.T) {
	row := &dutyroster.DutyRoster{
		ID:        uuidv7.MustBytes(),
		BaseID:    uuidv7.MustBytes(),
		DutyDate:  time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
		UpdatedBy: uuidv7.MustBytes(),
		CreatedBy: uuidv7.MustBytes(),
	}
	crews := []dutyroster.DutyRosterCrew{{
		RoleCode: "pilot",
		CrewType: "main",
		UserID:   uuidv7.MustBytes(),
	}}

	t.Run("create inTx error", func(t *testing.T) {
		svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}, inTxErr: errors.New("tx failed")})
		if err := svc.Create(context.Background(), row, crews); err == nil {
			t.Fatalf("expected create tx error")
		}
	})

	t.Run("update with no crews", func(t *testing.T) {
		tx := &dutyRosterTxRepoMock{
			lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
				return &dutyroster.DutyRoster{ID: row.ID, BaseID: row.BaseID, DutyDate: row.DutyDate}, nil
			},
		}
		svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx})
		if err := svc.Update(context.Background(), row, nil); err != nil {
			t.Fatalf("expected update without crews success, got %v", err)
		}
	})

	t.Run("delete lock error", func(t *testing.T) {
		tx := &dutyRosterTxRepoMock{
			lockFn: func(_ context.Context, _ []byte) (*dutyroster.DutyRoster, error) {
				return nil, errors.New("lock failed")
			},
		}
		svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: tx})
		if err := svc.Delete(context.Background(), row.ID, row.UpdatedBy); err == nil {
			t.Fatalf("expected delete lock error")
		}
	})
}

func TestDutyRosterService_ParsingAndValidationExtraBranches(t *testing.T) {
	// Validate delete shift_date branches
	start := time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC)
	end := time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC)
	if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "pilot", DateStart: &start}}); errObj == nil {
		t.Fatalf("expected start/end pair validation error")
	}
	if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "pilot", DateStart: &start, DateEnd: &end}}); errObj == nil {
		t.Fatalf("expected end >= start validation error")
	}
	if errObj := validateDutyRosterDeleteCrews([]dutyroster.DutyRosterCrew{{RoleCode: "other_person", DateStart: &start}}); errObj != nil {
		t.Fatalf("other_person should skip shift_date validation, got %#v", errObj)
	}

	if got := normalizeClockOrDefaultForCrew("", "07:00:00"); got != "07:00:00" {
		t.Fatalf("expected default clock, got %q", got)
	}
	if got, errObj := normalizeClockInputForCrew("bad", "", "/p"); errObj == nil || got != "" {
		t.Fatalf("expected invalid clock error")
	}

	// Parse shift range single token + invalid second token
	s, e := ParseDutyRosterShiftRange("start 09:15")
	if s != "09:15:00" || e != "09:15:00" {
		t.Fatalf("expected single token shift, got %q-%q", s, e)
	}
	s, e = ParseDutyRosterShiftRange("09:00 xx:yy")
	if s != "09:00:00" || e != "09:00:00" {
		t.Fatalf("expected invalid second token fallback to start, got %q-%q", s, e)
	}
	if len(extractClockTokensForShift("")) != 0 {
		t.Fatalf("expected no tokens for empty shift text")
	}

	now := time.Date(2026, 3, 18, 10, 0, 0, 0, time.UTC)
	from, to, errObj := ParseDutyRosterDateRangeOrDefault("2026-03-10", "", now)
	if errObj != nil || from.Format("2006-01-02") != "2026-03-10" || to.Format("2006-01-02") != "2026-03-31" {
		t.Fatalf("unexpected only-from parse result")
	}
	from, to, errObj = ParseDutyRosterDateRangeOrDefault("", "2026-03-20", now)
	if errObj != nil || from.Format("2006-01-02") != "2026-03-01" || to.Format("2006-01-02") != "2026-03-20" {
		t.Fatalf("unexpected only-to parse result")
	}
}

func TestDutyRosterService_BuildDeleteCrewsAndUUIDExtraBranches(t *testing.T) {
	actor := uuidv7.MustBytes()
	// nil detail branch
	rows, errObj := BuildDutyRosterDeleteCrews([]byte(`{"data":{"attributes":{}}}`), actor)
	if errObj != nil || rows != nil {
		t.Fatalf("expected nil rows when detail omitted, got rows=%v err=%#v", rows, errObj)
	}
	// invalid payload branch
	if _, errObj = BuildDutyRosterDeleteCrews([]byte(`not-json`), actor); errObj == nil {
		t.Fatalf("expected invalid delete payload error")
	}

	// uuid conversion error branch
	if got := uuidStringOrEmpty(context.Background(), []byte{1, 2, 3}); got != "" {
		t.Fatalf("expected empty string for invalid uuid bytes, got %q", got)
	}

	if out, errObj := normalizeClockInputForCrew("", "", "/p"); errObj != nil || out != "" {
		t.Fatalf("expected empty clock allowed, got out=%q err=%#v", out, errObj)
	}
	if s, e := ParseDutyRosterShiftRange("99:99 09:00"); s != "" || e != "09:00:00" {
		t.Fatalf("expected invalid first token ignored by parse, got %q-%q", s, e)
	}
	if s, e := ParseDutyRosterShiftRange("09:00 99:99"); s != "09:00:00" || e != "" {
		t.Fatalf("expected invalid second token cleared, got %q-%q", s, e)
	}

	svc := NewDutyRosterService(&dutyRosterRepoWithTxMock{txRepo: &dutyRosterTxRepoMock{}})
	rows2, err := svc.DeleteCrews(context.Background(), uuidv7.MustBytes(), nil, uuidv7.MustBytes())
	if err != nil || len(rows2) != 0 {
		t.Fatalf("expected empty delete crews shortcut, got rows=%v err=%v", rows2, err)
	}
}

func TestDutyRosterService_GroupMergeExtraBranches(t *testing.T) {
	empty := GroupRostersByBase(nil)
	merged := MergeEmptyBaseGroups(empty, []dutyroster.BaseView{{ID: uuidv7.MustBytes(), BaseName: "H1", BaseAbbreviation: "H1"}})
	if len(merged) != 1 || len(merged[0].Attributes.Rosters) != 0 {
		t.Fatalf("expected one empty hems base group, got %#v", merged)
	}

}

func TestDutyRosterResource_AllRoleDetails(t *testing.T) {
	header := dutyroster.HeaderView{
		ID:               uuidv7.MustBytes(),
		BaseID:           uuidv7.MustBytes(),
		BaseName:         "Base B",
		BaseAbbreviation: "BB",
		DutyDate:         time.Date(2026, 3, 21, 0, 0, 0, 0, time.UTC),
		CreatedAt:        time.Date(2026, 3, 21, 1, 0, 0, 0, time.UTC),
		UpdatedAt:        time.Date(2026, 3, 21, 2, 0, 0, 0, time.UTC),
	}
	crews := []dutyroster.CrewView{
		{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot Main", RoleCode: "pilot", CrewType: "main", Supervisor: true},
		{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Pilot Main", RoleCode: "pilot", CrewType: "main", Supervisor: true},
		{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Doctor", RoleCode: "doctor"},
		{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Rescuer", RoleCode: "rescuer"},
		{ID: uuidv7.MustBytes(), UserID: uuidv7.MustBytes(), Name: "Helper", RoleCode: "helper"},
		{ID: uuidv7.MustBytes(), Name: "Guest", RoleCode: "other_person", Email: "guest@example.com"},
		{ID: uuidv7.MustBytes(), Name: "Unknown", RoleCode: "unknown"},
	}
	got := DutyRosterResource(context.Background(), header, crews)
	if got.Attributes.ShiftTime.ShiftStart != "" || got.Attributes.ShiftTime.ShiftEnd != "" {
		t.Fatalf("expected empty header shift in response when source empty")
	}
	if len(got.Attributes.Detail.Pilot) != 2 || len(got.Attributes.Detail.Doctor) != 1 || len(got.Attributes.Detail.Rescuer) != 1 || len(got.Attributes.Detail.Helper) != 1 {
		t.Fatalf("expected all operational buckets mapped, got %#v", got.Attributes.Detail)
	}
	if len(got.Attributes.Detail.OtherPerson) != 2 {
		t.Fatalf("expected unknown role falls into other_person bucket, got %d", len(got.Attributes.Detail.OtherPerson))
	}
}

func mustUUID(t *testing.T) string {
	t.Helper()
	v, err := uuidv7.BytesToString(uuidv7.MustBytes())
	if err != nil {
		t.Fatalf("uuid to string: %v", err)
	}
	return v
}
