package hr

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

	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/db"
)

type fakeStore struct {
	emps     map[int64]db.GetEmployeeByIDRow
	atts     map[int64]db.GetAttendanceByIDRow
	leaves   map[int64]db.GetLeaveByIDRow
	pays     map[int64]db.GetPayrollByIDRow
	perfs    map[int64]db.ListPerformancesRow
	empNext  int64
	attNext  int64
	leafNext int64
	payNext  int64
	perfNext int64
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		emps: map[int64]db.GetEmployeeByIDRow{}, atts: map[int64]db.GetAttendanceByIDRow{},
		leaves: map[int64]db.GetLeaveByIDRow{}, pays: map[int64]db.GetPayrollByIDRow{},
		perfs:   map[int64]db.ListPerformancesRow{},
		empNext: 1, attNext: 1, leafNext: 1, payNext: 1, perfNext: 1,
	}
}

func (f *fakeStore) ListEmployees(context.Context, Filter, string, *bool) ([]db.ListEmployeesRow, int64, error) {
	return nil, 0, nil
}
func (f *fakeStore) EmployeeByID(_ context.Context, id int64) (db.GetEmployeeByIDRow, error) {
	if e, ok := f.emps[id]; ok {
		return e, nil
	}
	return db.GetEmployeeByIDRow{}, ErrNotFound
}
func (f *fakeStore) EmployeeByUserID(_ context.Context, uid int64) (db.GetEmployeeByUserIDRow, error) {
	for _, e := range f.emps {
		if e.UserID.Valid && e.UserID.Int64 == uid {
			return db.GetEmployeeByUserIDRow(e), nil
		}
	}
	return db.GetEmployeeByUserIDRow{}, ErrNotFound
}
func (f *fakeStore) CreateEmployee(_ context.Context, p db.CreateEmployeeParams) (db.CreateEmployeeRow, error) {
	row := db.CreateEmployeeRow{
		ID:              f.empNext,
		EmployeeNumber:  p.EmployeeNumber,
		UserID:          p.UserID,
		Name:            p.Name,
		Nik:             p.Nik,
		Department:      p.Department,
		JobLevel:        p.JobLevel,
		Position:        p.Position,
		EmploymentType:  p.EmploymentType,
		JoinDate:        p.JoinDate,
		EndDate:         p.EndDate,
		BasicSalary:           p.BasicSalary,
		Allowance:             p.Allowance,
		Phone:                 p.Phone,
		Email:                 p.Email,
		Address:               p.Address,
		City:                  p.City,
		BankName:              p.BankName,
		BankAccount:           p.BankAccount,
		BankAccountName:       p.BankAccountName,
		Npwp:                  p.Npwp,
		BpjsKetenagakerjaanNo: p.BpjsKetenagakerjaanNo,
		BpjsKesehatanNo:       p.BpjsKesehatanNo,
		Photo:                 p.Photo,
		IsActive:              p.IsActive,
	}
	f.empNext++
	f.emps[row.ID] = db.GetEmployeeByIDRow(row)
	return row, nil
}
func (f *fakeStore) UpdateEmployee(_ context.Context, p db.UpdateEmployeeParams) error {
	e, ok := f.emps[p.ID]
	if !ok {
		return ErrNotFound
	}
	e.UserID = p.UserID
	e.Name = p.Name
	e.Nik = p.Nik
	e.Department = p.Department
	e.JobLevel = p.JobLevel
	e.Position = p.Position
	e.EmploymentType = p.EmploymentType
	e.JoinDate = p.JoinDate
	e.EndDate = p.EndDate
	e.BasicSalary = p.BasicSalary
	e.Allowance = p.Allowance
	e.Phone = p.Phone
	e.Email = p.Email
	e.Address = p.Address
	e.City = p.City
	e.BankName = p.BankName
	e.BankAccount = p.BankAccount
	e.BankAccountName = p.BankAccountName
	e.Npwp = p.Npwp
	e.BpjsKetenagakerjaanNo = p.BpjsKetenagakerjaanNo
	e.BpjsKesehatanNo = p.BpjsKesehatanNo
	e.Photo = p.Photo
	e.IsActive = p.IsActive
	f.emps[p.ID] = e
	return nil
}
func (f *fakeStore) DeleteEmployee(context.Context, int64) error { return nil }
func (f *fakeStore) GetLatestEmployeeNumber(context.Context) (string, error) { return "EMP-001", nil }

func (f *fakeStore) ListAttendance(context.Context, Filter, *int64) ([]db.ListAttendanceRow, int64, error) {
	return nil, 0, nil
}
func (f *fakeStore) AttendanceByID(_ context.Context, id int64) (db.GetAttendanceByIDRow, error) {
	if a, ok := f.atts[id]; ok {
		return a, nil
	}
	return db.GetAttendanceByIDRow{}, ErrNotFound
}
func (f *fakeStore) AttendanceForDay(_ context.Context, empID int64, date string) (db.GetAttendanceForDayRow, error) {
	for _, a := range f.atts {
		if a.EmployeeID == empID && a.AttendanceDate == date {
			return db.GetAttendanceForDayRow(a), nil
		}
	}
	return db.GetAttendanceForDayRow{}, ErrNotFound
}
func (f *fakeStore) CreateAttendance(_ context.Context, p db.CreateAttendanceParams) (db.CreateAttendanceRow, error) {
	row := db.CreateAttendanceRow{
		ID:             f.attNext,
		EmployeeID:     p.EmployeeID,
		AttendanceDate: pgdateStr(p.AttendanceDate),
		ClockIn:        p.ClockIn,
		ClockOut:       p.ClockOut,
		ClockInLat:     p.ClockInLat,
		ClockInLng:     p.ClockInLng,
		ClockOutLat:    p.ClockOutLat,
		ClockOutLng:    p.ClockOutLng,
		Status:         p.Status,
		WorkHours:      p.WorkHours,
		OvertimeHours:  p.OvertimeHours,
		Notes:          p.Notes,
	}
	f.attNext++
	f.atts[row.ID] = db.GetAttendanceByIDRow(row)
	return row, nil
}
func (f *fakeStore) UpdateAttendance(_ context.Context, p db.UpdateAttendanceParams) error {
	a, ok := f.atts[p.ID]
	if !ok {
		return ErrNotFound
	}
	a.ClockIn = p.ClockIn
	a.ClockOut = p.ClockOut
	a.ClockInLat = p.ClockInLat
	a.ClockInLng = p.ClockInLng
	a.ClockOutLat = p.ClockOutLat
	a.ClockOutLng = p.ClockOutLng
	a.Status = p.Status
	a.WorkHours = p.WorkHours
	a.OvertimeHours = p.OvertimeHours
	a.Notes = p.Notes
	f.atts[p.ID] = a
	return nil
}

func (f *fakeStore) UpsertLeaveAttendance(_ context.Context, p db.UpsertLeaveAttendanceParams) error {
	dateStr := pgdateStr(p.AttendanceDate)
	for id, a := range f.atts {
		if a.EmployeeID == p.EmployeeID && a.AttendanceDate == dateStr {
			a.Status = "leave"
			a.Notes = p.Notes
			f.atts[id] = a
			return nil
		}
	}
	f.atts[f.attNext] = db.GetAttendanceByIDRow{
		ID:             f.attNext,
		EmployeeID:     p.EmployeeID,
		AttendanceDate: dateStr,
		Status:         "leave",
		Notes:          p.Notes,
	}
	f.attNext++
	return nil
}

func (f *fakeStore) ListAttendanceForEmployeePeriod(_ context.Context, p db.ListAttendanceForEmployeePeriodParams) ([]db.ListAttendanceForEmployeePeriodRow, error) {
	var out []db.ListAttendanceForEmployeePeriodRow
	for _, a := range f.atts {
		if a.EmployeeID == p.EmployeeID {
			out = append(out, db.ListAttendanceForEmployeePeriodRow{
				ID:             a.ID,
				EmployeeID:     a.EmployeeID,
				AttendanceDate: a.AttendanceDate,
				Status:         a.Status,
				WorkHours:      a.WorkHours,
				OvertimeHours:  a.OvertimeHours,
				Notes:          a.Notes,
			})
		}
	}
	return out, nil
}

func (f *fakeStore) ListApprovedLeavesForEmployeePeriod(_ context.Context, p db.ListApprovedLeavesForEmployeePeriodParams) ([]db.ListApprovedLeavesForEmployeePeriodRow, error) {
	var out []db.ListApprovedLeavesForEmployeePeriodRow
	for _, l := range f.leaves {
		if l.EmployeeID == p.EmployeeID && l.Status == "approved" {
			out = append(out, db.ListApprovedLeavesForEmployeePeriodRow{
				ID:         l.ID,
				EmployeeID: l.EmployeeID,
				LeaveType:  l.LeaveType,
				StartDate:  l.StartDate,
				EndDate:    l.EndDate,
				TotalDays:  l.TotalDays,
				Status:     l.Status,
			})
		}
	}
	return out, nil
}

func (f *fakeStore) ListLeaves(context.Context, Filter, *int64) ([]db.ListLeavesRow, int64, error) {
	return nil, 0, nil
}
func (f *fakeStore) LeaveByID(_ context.Context, id int64) (db.GetLeaveByIDRow, error) {
	if l, ok := f.leaves[id]; ok {
		return l, nil
	}
	return db.GetLeaveByIDRow{}, ErrNotFound
}
func (f *fakeStore) CreateLeave(_ context.Context, p db.CreateLeaveParams) (db.CreateLeaveRow, error) {
	row := db.CreateLeaveRow{
		ID:         f.leafNext,
		EmployeeID: p.EmployeeID,
		LeaveType:  p.LeaveType,
		StartDate:  pgdateStr(p.StartDate),
		EndDate:    pgdateStr(p.EndDate),
		TotalDays:  p.TotalDays,
		Reason:     p.Reason,
		Status:     p.Status,
		Notes:      p.Notes,
	}
	f.leafNext++
	f.leaves[row.ID] = db.GetLeaveByIDRow(row)
	return row, nil
}
func (f *fakeStore) UpdateLeave(_ context.Context, p db.UpdateLeaveParams) error {
	l, ok := f.leaves[p.ID]
	if !ok {
		return ErrNotFound
	}
	l.Status = p.Status
	l.ApprovedBy = p.ApprovedBy
	l.ApprovedAt = p.ApprovedAt
	l.Notes = p.Notes
	f.leaves[p.ID] = l
	return nil
}

func (f *fakeStore) DeductEmployeeLeave(_ context.Context, p db.DeductEmployeeLeaveParams) (decimal.Decimal, error) {
	e, ok := f.emps[p.ID]
	if !ok {
		return decimal.Zero, ErrNotFound
	}
	if e.LeaveBalance.LessThan(p.LeaveBalance) {
		return e.LeaveBalance, errors.New("insufficient leave balance")
	}
	e.LeaveBalance = e.LeaveBalance.Sub(p.LeaveBalance)
	e.LeaveUsed = e.LeaveUsed.Add(p.LeaveBalance)
	f.emps[p.ID] = e
	return e.LeaveBalance, nil
}

func (f *fakeStore) ListPayrolls(context.Context, Filter, *int64) ([]db.ListPayrollsRow, int64, error) {
	return nil, 0, nil
}
func (f *fakeStore) PayrollByID(_ context.Context, id int64) (db.GetPayrollByIDRow, error) {
	if p, ok := f.pays[id]; ok {
		return p, nil
	}
	return db.GetPayrollByIDRow{}, ErrNotFound
}
func (f *fakeStore) CreatePayroll(_ context.Context, p db.CreatePayrollParams) (db.CreatePayrollRow, error) {
	row := db.CreatePayrollRow{
		ID:                  f.payNext,
		PayrollNumber:       p.PayrollNumber,
		EmployeeID:          p.EmployeeID,
		PeriodMonth:         p.PeriodMonth,
		PeriodYear:          p.PeriodYear,
		BasicSalary:         p.BasicSalary,
		Allowances:          p.Allowances,
		OvertimePay:         p.OvertimePay,
		Bonus:               p.Bonus,
		Deductions:          p.Deductions,
		BpjsKetenagakerjaan: p.BpjsKetenagakerjaan,
		BpjsKesehatan:       p.BpjsKesehatan,
		Pph21:               p.Pph21,
		NetSalary:           p.NetSalary,
		PaymentDate:         pgdateStr(p.PaymentDate),
		Status:              p.Status,
		Notes:               p.Notes,
	}
	f.payNext++
	f.pays[row.ID] = db.GetPayrollByIDRow(row)
	return row, nil
}
func (f *fakeStore) UpdatePayroll(_ context.Context, p db.UpdatePayrollParams) error {
	pay, ok := f.pays[p.ID]
	if !ok {
		return ErrNotFound
	}
	pay.Status = p.Status
	pay.Notes = p.Notes
	f.pays[p.ID] = pay
	return nil
}
func (f *fakeStore) UpsertPayroll(_ context.Context, p db.UpsertPayrollParams) (db.UpsertPayrollRow, error) {
	for id, pay := range f.pays {
		if pay.EmployeeID == p.EmployeeID && pay.PeriodMonth == p.PeriodMonth && pay.PeriodYear == p.PeriodYear {
			pay.BasicSalary = p.BasicSalary
			pay.Allowances = p.Allowances
			pay.OvertimePay = p.OvertimePay
			pay.Bonus = p.Bonus
			pay.Deductions = p.Deductions
			pay.BpjsKetenagakerjaan = p.BpjsKetenagakerjaan
			pay.BpjsKesehatan = p.BpjsKesehatan
			pay.Pph21 = p.Pph21
			pay.NetSalary = p.NetSalary
			pay.Notes = p.Notes
			f.pays[id] = pay
			return db.UpsertPayrollRow(pay), nil
		}
	}
	row := db.GetPayrollByIDRow{
		ID:                  f.payNext,
		PayrollNumber:       p.PayrollNumber,
		EmployeeID:          p.EmployeeID,
		PeriodMonth:         p.PeriodMonth,
		PeriodYear:          p.PeriodYear,
		BasicSalary:         p.BasicSalary,
		Allowances:          p.Allowances,
		OvertimePay:         p.OvertimePay,
		Bonus:               p.Bonus,
		Deductions:          p.Deductions,
		BpjsKetenagakerjaan: p.BpjsKetenagakerjaan,
		BpjsKesehatan:       p.BpjsKesehatan,
		Pph21:               p.Pph21,
		NetSalary:           p.NetSalary,
		PaymentDate:         pgdateStr(p.PaymentDate),
		Status:              p.Status,
		Notes:               p.Notes,
	}
	f.payNext++
	f.pays[row.ID] = row
	return db.UpsertPayrollRow(row), nil
}
func (f *fakeStore) PayPayroll(_ context.Context, p db.PayPayrollParams) error {
	pay, ok := f.pays[p.ID]
	if !ok {
		return ErrNotFound
	}
	pay.Status = "paid"
	pay.PaymentDate = pgdateStr(p.PaymentDate)
	pay.Notes = p.Notes
	f.pays[p.ID] = pay
	return nil
}
func (f *fakeStore) ActiveEmployees(_ context.Context) ([]db.ListActiveEmployeesRow, error) {
	out := []db.ListActiveEmployeesRow{}
	for _, e := range f.emps {
		if e.IsActive {
			out = append(out, db.ListActiveEmployeesRow(e))
		}
	}
	return out, nil
}

func (f *fakeStore) ListPerformances(context.Context, Filter) ([]db.ListPerformancesRow, int64, error) {
	return nil, 0, nil
}
func (f *fakeStore) PerformanceByID(_ context.Context, id int64) (db.GetPerformanceByIDRow, error) {
	if p, ok := f.perfs[id]; ok {
		return db.GetPerformanceByIDRow{
			ID:               p.ID,
			EmployeeID:       p.EmployeeID,
			EvaluatorID:      p.EvaluatorID,
			PeriodMonth:      p.PeriodMonth,
			PeriodYear:       p.PeriodYear,
			KpiScore:         p.KpiScore,
			AttitudeScore:    p.AttitudeScore,
			TeamworkScore:    p.TeamworkScore,
			AchievementScore: p.AchievementScore,
			TotalScore:       p.TotalScore,
			Grade:            p.Grade,
			Achievements:     p.Achievements,
			Improvements:     p.Improvements,
			Notes:            p.Notes,
			EmployeeName:     p.EmployeeName,
			EvaluatorName:    p.EvaluatorName,
		}, nil
	}
	return db.GetPerformanceByIDRow{}, ErrNotFound
}
func (f *fakeStore) CreatePerformance(_ context.Context, p db.CreatePerformanceParams) (db.CreatePerformanceRow, error) {
	row := db.CreatePerformanceRow{
		ID:               f.perfNext,
		EmployeeID:       p.EmployeeID,
		EvaluatorID:      p.EvaluatorID,
		PeriodMonth:      p.PeriodMonth,
		PeriodYear:       p.PeriodYear,
		KpiScore:         p.KpiScore,
		AttitudeScore:    p.AttitudeScore,
		TeamworkScore:    p.TeamworkScore,
		AchievementScore: p.AchievementScore,
		TotalScore:       p.TotalScore,
		Grade:            p.Grade,
		Achievements:     p.Achievements,
		Improvements:     p.Improvements,
		Notes:            p.Notes,
	}
	f.perfNext++
	f.perfs[row.ID] = db.ListPerformancesRow{
		ID: row.ID, EmployeeID: row.EmployeeID, EvaluatorID: row.EvaluatorID,
		PeriodMonth: row.PeriodMonth, PeriodYear: row.PeriodYear,
		KpiScore: row.KpiScore, AttitudeScore: row.AttitudeScore, TeamworkScore: row.TeamworkScore,
		AchievementScore: row.AchievementScore, TotalScore: row.TotalScore,
		Grade: row.Grade, Achievements: row.Achievements, Improvements: row.Improvements, Notes: row.Notes,
	}
	return row, nil
}
func (f *fakeStore) UpdatePerformance(_ context.Context, p db.UpdatePerformanceParams) error {
	perf, ok := f.perfs[p.ID]
	if !ok {
		return ErrNotFound
	}
	perf.KpiScore = p.KpiScore
	perf.AttitudeScore = p.AttitudeScore
	perf.TeamworkScore = p.TeamworkScore
	perf.AchievementScore = p.AchievementScore
	perf.TotalScore = p.TotalScore
	perf.Grade = p.Grade
	perf.Achievements = p.Achievements
	perf.Improvements = p.Improvements
	perf.Notes = p.Notes
	f.perfs[p.ID] = perf
	return nil
}
func (f *fakeStore) DeletePerformance(_ context.Context, id int64) error {
	if _, ok := f.perfs[id]; !ok {
		return ErrNotFound
	}
	delete(f.perfs, id)
	return nil
}

type fakeSeq struct{ n int64 }

func (f *fakeSeq) Sequence(context.Context, string, time.Time, any) (int64, error) {
	f.n++
	return f.n, nil
}

// newSvc takes the Store interface rather than the concrete fake so a test can
// wrap the fake to make one call fail.
func newSvc(s Store) *Service {
	return newSvcWithSeq(s, &fakeSeq{}, time.FixedZone("WIB", 7*3600))
}

// newSvcWithSeq additionally takes the allocator, so a test can model a real
// allocator shape (per-date buckets, concurrency) instead of a counting stub.
func newSvcWithSeq(s Store, sq Seq, loc *time.Location) *Service {
	return NewService(s, sq, loc)
}

func bindEmployee(s *fakeStore, uid int64) *Employee {
	e, err := s.CreateEmployee(context.Background(), db.CreateEmployeeParams{
		EmployeeNumber: "EMP-001",
		UserID:         pgtype.Int8{Int64: uid, Valid: true},
		Name:           "Budi",
		Nik:            pgtype.Text{String: "3201", Valid: true},
		IsActive:       true,
	})
	if err != nil {
		panic(err)
	}
	emp := s.emps[e.ID]
	emp.LeaveBalance = decimal.NewFromInt(30)
	s.emps[e.ID] = emp
	return employeeFromRow(db.GetEmployeeByIDRow(e))
}

func pgdateStr(d pgtype.Date) string {
	if !d.Valid {
		return ""
	}
	return d.Time.Format("2006-01-02")
}

func TestCreateEmployee_CodeAndDefaults(t *testing.T) {
	svc := newSvc(newFakeStore())
	e, err := svc.CreateEmployee(context.Background(), EmployeeInput{Name: "Budi", NIK: "3201"})
	if err != nil {
		t.Fatal(err)
	}
	if e.EmployeeNumber != "EMP-001" {
		t.Errorf("number = %q, want EMP-001", e.EmployeeNumber)
	}
	if e.Department != "operations" || e.EmploymentType != "permanent" {
		t.Errorf("defaults not applied: %q %q", e.Department, e.EmploymentType)
	}
}

func TestCreateEmployee_RequiresNameAndNIK(t *testing.T) {
	svc := newSvc(newFakeStore())
	if _, err := svc.CreateEmployee(context.Background(), EmployeeInput{NIK: "x"}); err == nil {
		t.Fatal("want error for empty name")
	}
	if _, err := svc.CreateEmployee(context.Background(), EmployeeInput{Name: "x"}); err == nil {
		t.Fatal("want error for empty nik")
	}
}

func TestClockIn_OncePerDay(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	bindEmployee(s, 7)
	if _, err := svc.ClockIn(context.Background(), 7, ClockInOutInput{}); err != nil {
		t.Fatal(err)
	}
	if _, err := svc.ClockIn(context.Background(), 7, ClockInOutInput{}); !errors.Is(err, ErrAlreadyClockedIn) {
		t.Fatalf("got %v, want ErrAlreadyClockedIn", err)
	}
}

func TestClockOut_ComputesHours(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	bindEmployee(s, 7)
	day := time.Now().In(svc.loc).Format("2006-01-02")
	ci := time.Now().In(svc.loc).Add(-9 * time.Hour)
	if _, err := s.CreateAttendance(context.Background(), db.CreateAttendanceParams{
		EmployeeID:     1,
		AttendanceDate: dateOrNil(day),
		ClockIn:        tsToPgtz(&ci),
		Status:         "present",
	}); err != nil {
		t.Fatal(err)
	}
	rec, err := svc.ClockOut(context.Background(), 7, ClockInOutInput{})
	if err != nil {
		t.Fatal(err)
	}
	if rec.WorkHours == nil || rec.WorkHours.Round(0).LessThan(decimal.NewFromInt(8)) {
		t.Fatalf("work_hours = %v, want >= 8", rec.WorkHours)
	}
	if rec.OvertimeHours == nil || rec.OvertimeHours.IsNegative() {
		t.Fatalf("overtime_hours = %v, want >= 0", rec.OvertimeHours)
	}
}

func TestClockOut_WithoutClockIn(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	bindEmployee(s, 7)
	if _, err := svc.ClockOut(context.Background(), 7, ClockInOutInput{}); !errors.Is(err, ErrNotClockedIn) {
		t.Fatalf("got %v, want ErrNotClockedIn", err)
	}
}

func TestLeave_TotalDaysAndApproval(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	bindEmployee(s, 7)
	l, err := svc.CreateLeave(context.Background(), 7, LeaveInput{
		LeaveType: "annual", StartDate: "2026-08-10", EndDate: "2026-08-12", Reason: "Libur",
	})
	if err != nil {
		t.Fatal(err)
	}
	if l.TotalDays != 3 {
		t.Errorf("total_days = %d, want 3", l.TotalDays)
	}
	if l.Status != "pending" {
		t.Errorf("status = %q, want pending", l.Status)
	}
	app, err := svc.ApproveLeave(context.Background(), l.ID, 99)
	if err != nil {
		t.Fatal(err)
	}
	if app.Status != "approved" || app.ApprovedBy == nil || *app.ApprovedBy != 99 {
		t.Errorf("approval not recorded: %+v", app)
	}
	if _, err := svc.RejectLeave(context.Background(), l.ID, 1); !errors.Is(err, ErrNotPending) {
		t.Fatalf("got %v, want ErrNotPending on re-review", err)
	}
}

func TestLeave_InvalidDates(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	bindEmployee(s, 7)
	if _, err := svc.CreateLeave(context.Background(), 7, LeaveInput{
		LeaveType: "annual", StartDate: "2026-08-12", EndDate: "2026-08-10", Reason: "x",
	}); !errors.Is(err, ErrInvalidDates) {
		t.Fatalf("got %v, want ErrInvalidDates", err)
	}
}

func TestApproveLeave_DeductsLeaveBalance(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	emp := bindEmployee(s, 7)
	before := s.emps[emp.ID].LeaveBalance

	l, err := svc.CreateLeave(context.Background(), 7, LeaveInput{
		LeaveType: "annual", StartDate: "2026-08-10", EndDate: "2026-08-12", Reason: "Libur",
	})
	if err != nil {
		t.Fatal(err)
	}
	if l.TotalDays != 3 {
		t.Fatalf("total_days = %d, want 3", l.TotalDays)
	}
	if _, err := svc.ApproveLeave(context.Background(), l.ID, 99); err != nil {
		t.Fatal(err)
	}
	after := s.emps[emp.ID].LeaveBalance
	if !after.Equal(before.Sub(decimal.NewFromInt(3))) {
		t.Fatalf("leave_balance = %s, want %s (before %s - 3)", after, before.Sub(decimal.NewFromInt(3)), before)
	}
	used := s.emps[emp.ID].LeaveUsed
	if !used.Equal(decimal.NewFromInt(3)) {
		t.Fatalf("leave_used = %s, want 3", used)
	}
}

func TestApproveLeave_InsufficientBalance(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	emp := bindEmployee(s, 7)
	// Shrink the seeded balance below the requested leave duration.
	e := s.emps[emp.ID]
	e.LeaveBalance = decimal.NewFromInt(1)
	s.emps[emp.ID] = e

	l, err := svc.CreateLeave(context.Background(), 7, LeaveInput{
		LeaveType: "annual", StartDate: "2026-08-10", EndDate: "2026-08-12", Reason: "Libur",
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := svc.ApproveLeave(context.Background(), l.ID, 99); err == nil {
		t.Fatal("expected insufficient leave balance error")
	}
}

func TestGeneratePayrolls_BpjsRates(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	e := bindEmployee(s, 7)
	e.BasicSalary = decimal.NewFromInt(10_000_000)
	if err := s.UpdateEmployee(context.Background(), db.UpdateEmployeeParams{
		ID:          e.ID,
		UserID:      int8OrNil(e.UserID),
		Name:        e.Name,
		Nik:         textOrNil(e.NIK),
		Department:  e.Department,
		Position:    textOrNil(e.Position),
		IsActive:    e.IsActive,
		BasicSalary: e.BasicSalary,
	}); err != nil {
		t.Fatal(err)
	}
	list, err := svc.GeneratePayrolls(context.Background(), 8, 2026)
	if err != nil {
		t.Fatal(err)
	}
	if len(list) != 1 {
		t.Fatalf("len = %d, want 1", len(list))
	}
	p := list[0]
	if p.BpjsKetenagakerjaan.String() != "270000" {
		t.Errorf("bpjs_tk = %s, want 270000", p.BpjsKetenagakerjaan)
	}
	if p.BpjsKesehatan.String() != "100000" {
		t.Errorf("bpjs_kes = %s, want 100000", p.BpjsKesehatan)
	}
	if p.NetSalary.String() != "9630000" {
		t.Errorf("net = %s, want 9630000", p.NetSalary)
	}
	if p.Status != "draft" || p.PayrollNumber == "" {
		t.Errorf("draft/num not set: %+v", p)
	}
}

func TestPerformance_TotalAndGrade(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	p, err := svc.CreatePerformance(context.Background(), 1, PerformanceInput{
		EmployeeID: 2, PeriodMonth: 8, PeriodYear: 2026,
		KPIScore: decimal.NewFromInt(90), AttitudeScore: decimal.NewFromInt(80),
		TeamworkScore: decimal.NewFromInt(70), AchievementScore: decimal.NewFromInt(100),
	})
	if err != nil {
		t.Fatal(err)
	}
	if p.TotalScore.String() != "85" {
		t.Errorf("total = %s, want 85", p.TotalScore)
	}
	if p.Grade != "B" {
		t.Errorf("grade = %q, want B", p.Grade)
	}
	if p.EvaluatorID == nil || *p.EvaluatorID != 1 {
		t.Errorf("evaluator_id not set")
	}
}

func TestPerformance_ScoreRange(t *testing.T) {
	svc := newSvc(newFakeStore())
	if _, err := svc.CreatePerformance(context.Background(), 1, PerformanceInput{
		EmployeeID: 2, PeriodMonth: 8, PeriodYear: 2026, KPIScore: decimal.NewFromInt(101),
	}); err == nil {
		t.Fatal("want error for score > 100")
	}
}

func TestLeave_ApproveAutoAttendanceAndUnpaid(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	emp := bindEmployee(s, 10)

	// 1. Annual leave deducts quota and creates auto-attendance
	l1, err := svc.CreateLeave(context.Background(), 10, LeaveInput{
		LeaveType: "annual", StartDate: "2026-08-01", EndDate: "2026-08-02", Reason: "Libur",
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := svc.ApproveLeave(context.Background(), l1.ID, 99); err != nil {
		t.Fatal(err)
	}
	// Check attendance auto-created
	att1, err := s.AttendanceForDay(context.Background(), emp.ID, "2026-08-01")
	if err != nil || att1.Status != "leave" {
		t.Fatalf("expected attendance row with status leave for 2026-08-01, got: %+v, err: %v", att1, err)
	}
	att2, err := s.AttendanceForDay(context.Background(), emp.ID, "2026-08-02")
	if err != nil || att2.Status != "leave" {
		t.Fatalf("expected attendance row with status leave for 2026-08-02, got: %+v, err: %v", att2, err)
	}

	// 2. Unpaid leave does NOT deduct annual leave balance
	balBefore := s.emps[emp.ID].LeaveBalance
	l2, err := svc.CreateLeave(context.Background(), 10, LeaveInput{
		LeaveType: "unpaid", StartDate: "2026-08-05", EndDate: "2026-08-06", Reason: "Urusan Keluarga",
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := svc.ApproveLeave(context.Background(), l2.ID, 99); err != nil {
		t.Fatal(err)
	}
	balAfter := s.emps[emp.ID].LeaveBalance
	if !balAfter.Equal(balBefore) {
		t.Fatalf("unpaid leave should not deduct balance: before %s, after %s", balBefore, balAfter)
	}
}

func TestGeneratePayrolls_AttendanceDeductionsAndOvertime(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	emp := bindEmployee(s, 11)
	e := s.emps[emp.ID]
	e.BasicSalary = decimal.NewFromInt(5_000_000)
	e.Allowance = decimal.NewFromInt(500_000)
	s.emps[emp.ID] = e

	// Seed attendance:
	// 2 late arrivals (penalty 25,000 each = 50,000)
	// 10 hours overtime (hourly rate = 5,000,000 / 173 ≈ 28901.734 -> 10 * 28901.734 ≈ 289017.34)
	s.atts[101] = db.GetAttendanceByIDRow{
		ID:             101,
		EmployeeID:     emp.ID,
		AttendanceDate: "2026-08-01",
		Status:         "late",
		OvertimeHours:  decToNd(decimal.NewFromInt(5)),
	}
	s.atts[102] = db.GetAttendanceByIDRow{
		ID:             102,
		EmployeeID:     emp.ID,
		AttendanceDate: "2026-08-02",
		Status:         "late",
		OvertimeHours:  decToNd(decimal.NewFromInt(5)),
	}

	payrolls, err := svc.GeneratePayrolls(context.Background(), 8, 2026)
	if err != nil {
		t.Fatal(err)
	}
	if len(payrolls) != 1 {
		t.Fatalf("expected 1 payroll, got %d", len(payrolls))
	}
	p := payrolls[0]

	// Overtime pay: 10 * (5,000,000 / 173) = 289,017.34
	expectedOvertime := decimal.NewFromInt(10).Mul(decimal.NewFromInt(5_000_000).Div(decimal.NewFromInt(173))).Round(2)
	if !p.OvertimePay.Equal(expectedOvertime) {
		t.Errorf("overtime_pay = %s, want %s", p.OvertimePay, expectedOvertime)
	}

	// Late penalty: 2 * 25,000 = 50,000
	if p.Deductions.String() != "50000" {
		t.Errorf("deductions = %s, want 50000", p.Deductions)
	}

	// BPJS TK: 2.7% * 5,000,000 = 135,000
	// BPJS Kes: 1% * 5,000,000 = 50,000
	// Gross = 5,000,000 + 500,000 + 289,017.34 = 5,789,017.34
	// Net = Gross - deductions(50,000) - bpjsTK(135,000) - bpjsKes(50,000)
	expectedNet := p.BasicSalary.Add(p.Allowances).Add(p.OvertimePay).Sub(p.Deductions).Sub(p.BpjsKetenagakerjaan).Sub(p.BpjsKesehatan).Round(2)
	if !p.NetSalary.Equal(expectedNet) {
		t.Errorf("net_salary = %s, want %s", p.NetSalary, expectedNet)
	}
}

func TestPayPayroll_Disbursement(t *testing.T) {
	s := newFakeStore()
	svc := newSvc(s)
	emp := bindEmployee(s, 12)
	e := s.emps[emp.ID]
	e.BasicSalary = decimal.NewFromInt(6_000_000)
	s.emps[emp.ID] = e

	payrolls, err := svc.GeneratePayrolls(context.Background(), 8, 2026)
	if err != nil {
		t.Fatal(err)
	}
	p := payrolls[0]

	// Cannot pay draft payroll directly
	if _, err := svc.PayPayroll(context.Background(), p.ID, PayPayrollInput{PaymentMethod: "cash"}, nil); err == nil {
		t.Fatal("expected error paying draft payroll before approval")
	}

	// Approve payroll
	approved, err := svc.ApprovePayroll(context.Background(), p.ID)
	if err != nil {
		t.Fatal(err)
	}
	if approved.Status != "approved" {
		t.Fatalf("expected approved status, got %s", approved.Status)
	}

	// Set up disbursement hook tracking
	disbursed := false
	var disbursedAcct string
	var disbursedAmount decimal.Decimal
	svc.WithDisbursement(func(ctx context.Context, p *Payroll, acctCode string, userID *int64) (string, error) {
		disbursed = true
		disbursedAcct = acctCode
		disbursedAmount = p.NetSalary
		return "JV-202608-0001", nil
	})

	paid, err := svc.PayPayroll(context.Background(), p.ID, PayPayrollInput{
		PaymentMethod: "bank_transfer",
		AccountCode:   "1000.03.01",
		PaymentDate:   "2026-08-28",
		Notes:         "Gaji Agustus 2026",
	}, nil)
	if err != nil {
		t.Fatal(err)
	}

	if paid.Status != "paid" {
		t.Fatalf("expected paid status, got %s", paid.Status)
	}
	if !disbursed {
		t.Fatal("disbursement hook was not called")
	}
	if disbursedAcct != "1000.03.01" {
		t.Errorf("disbursedAcct = %s, want 1000.03.01", disbursedAcct)
	}
	if !disbursedAmount.Equal(paid.NetSalary) {
		t.Errorf("disbursedAmount = %s, want %s", disbursedAmount, paid.NetSalary)
	}
}

