package hr

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

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

// countingSeq records the value the allocator handed back on every call, so a
// test can assert the exact ordinals a run issued rather than inferring them.
type countingSeq struct {
	*monthBucketSeq
	returned []int64
}

func newCountingSeq() *countingSeq {
	return &countingSeq{monthBucketSeq: newMonthBucketSeq()}
}

func (c *countingSeq) Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error) {
	n, err := c.monthBucketSeq.Sequence(ctx, prefix, date, tx)
	if err == nil {
		c.returned = append(c.returned, n)
	}
	return n, err
}

// ordinalsOf returns the ordinal part of each stored payroll number, sorted.
func ordinalsOf(store *payrollStore) []string {
	out := make([]string, 0, len(store.holders))
	for num := range store.holders {
		out = append(out, num[strings.LastIndex(num, "-")+1:])
	}
	sort.Strings(out)
	return out
}

func heldBy(store *payrollStore, num string) string {
	return store.holders[num]
}

// TestPayrollNumber_WithinRunOrdinalsUnchanged pins the claim that made this fix
// safe: the ordinals a SINGLE run issues are exactly what the old `n + i`
// arithmetic produced.
//
// Old: one allocation of n, employee i got n+i, so a run of k employees
// received n, n+1, ... n+k-1.
// New: one allocation per employee, and seq.Next increments by exactly one per
// call, so the (i+1)-th call returns n+i - the same number.
//
// This test is deliberately a SAFETY test, not a regression test: it asserts
// only the ordinals that reached the table, so it passes against BOTH the old
// block allocation and the new per-employee allocation. That is the evidence
// that the fix does not change what any payslip is called. The tests that pin
// the fix itself are TestPayrollNumber_CounterTracksConsumption and
// TestPayrollNumber_RosterShrinksThenGrows, which fail against the old code.
func TestPayrollNumber_WithinRunOrdinalsUnchanged(t *testing.T) {
	t.Run("fresh bucket", func(t *testing.T) {
		store := newPayrollStore()
		for _, id := range []int64{1, 2, 3, 4} {
			store.addEmployee(id, "E")
		}
		svc := newSvcWithSeq(store, newCountingSeq(), time.FixedZone("WIB", 7*3600))

		out, err := svc.GeneratePayrolls(context.Background(), 9, 2026)
		if err != nil {
			t.Fatalf("run failed: %v", err)
		}
		if len(out) != 4 {
			t.Fatalf("produced %d payrolls, want 4", len(out))
		}

		// The exact sequence, in employee order. This is the property: it is
		// 0001..0004 under the old n+i arithmetic and under the new
		// one-call-per-employee arithmetic alike.
		want := []string{
			"PAY-202609-0001",
			"PAY-202609-0002",
			"PAY-202609-0003",
			"PAY-202609-0004",
		}
		for i, p := range out {
			if p.PayrollNumber != want[i] {
				t.Errorf("employee %d received %q, want %q", i+1, p.PayrollNumber, want[i])
			}
		}
	})

	t.Run("bucket already advanced", func(t *testing.T) {
		store := newPayrollStore()
		store.addEmployee(1, "A")
		store.addEmployee(2, "B")
		svc := newSvcWithSeq(store, newCountingSeq(), time.FixedZone("WIB", 7*3600))

		// September, so the bucket moves off 1 before the case under test.
		if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); err != nil {
			t.Fatalf("September run failed: %v", err)
		}
		// A second September run: the ordinals must continue where they left
		// off, not restart and not skip.
		out, err := svc.GeneratePayrolls(context.Background(), 9, 2026)
		if err != nil {
			t.Fatalf("second September run failed: %v", err)
		}
		// Both employees already have rows, so the upsert keeps their original
		// numbers. That is the pre-existing behaviour and the fix must not
		// disturb it: no renumbering.
		for _, p := range out {
			if p.PayrollNumber != "PAY-202609-0001" && p.PayrollNumber != "PAY-202609-0002" {
				t.Errorf("re-run changed an existing payslip to %q", p.PayrollNumber)
			}
		}
		if got := strings.Join(ordinalsOf(store), ","); got != "0001,0002" {
			t.Errorf("stored ordinals = %s, want 0001,0002 unchanged by a re-run", got)
		}
	})
}

// TestPayrollNumber_RosterShrinksThenGrows is the reachable failure, and it is
// the one a naive "two runs in a period work" test cannot see.
//
// A simple second run is masked by ON CONFLICT (employee_id, period_month,
// period_year) DO UPDATE: payroll_number is not in the SET list, so an existing
// row keeps the number it already had and no insert happens. The collision only
// appears when a NEW employee has to be inserted and the ordinal the allocator
// offers it is still held by a DEPARTED employee's row.
//
// Before the fix: run 1 takes one counter value and issues 0001..0003, leaving
// the counter at 1. When A and C leave, B stays and D joins, the second run
// takes n=2 and offers D the ordinal 2+1=3 - which C's row still holds. Insert
// fails on payrolls_payroll_number_key.
//
// After the fix: run 1 advances the counter to 3, so the second run offers D
// 0005, which nothing holds.
func TestPayrollNumber_RosterShrinksThenGrows(t *testing.T) {
	seq := newCountingSeq()
	store := newPayrollStore()
	store.addEmployee(1, "A")
	store.addEmployee(2, "B")
	store.addEmployee(3, "C")
	svc := newSvcWithSeq(store, seq, time.FixedZone("WIB", 7*3600))

	// Run 1: three employees, three payslips.
	if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); err != nil {
		t.Fatalf("first run failed: %v", err)
	}
	if got := strings.Join(ordinalsOf(store), ","); got != "0001,0002,0003" {
		t.Fatalf("after run 1 the ordinals are %s, want 0001,0002,0003", got)
	}
	// The counter must now reflect what was consumed. This is the whole fix.
	if last := seq.returned[len(seq.returned)-1]; last != 3 {
		t.Errorf("counter ended at %d, want 3: a run of three employees consumed three ordinals", last)
	}

	// A and C leave. Their payroll rows for the period REMAIN in the table -
	// a payslip is not deleted because an employee left - and still hold 0001
	// and 0003.
	store.setActive(1, false)
	store.setActive(3, false)
	if len(store.holders) != 3 {
		t.Fatalf("departed employees' payslips were removed; they must persist")
	}

	// D joins, and the period is run again.
	store.addEmployee(4, "D")
	if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); err != nil {
		t.Fatalf("re-run after a roster change collided: %v", err)
	}

	// C's departed row still holds 0003 and must still hold it: the fix
	// renumbers nothing.
	if got := heldBy(store, "PAY-202609-0003"); !strings.HasPrefix(got, "3/9/2026") {
		t.Errorf("PAY-202609-0003 is now held by %q, want employee 3's unchanged row", got)
	}
	// D must have received a number that was free.
	dKey := heldBy(store, "PAY-202609-0005")
	if dKey != "4/9/2026" {
		t.Errorf("employee 4 holds %q, want 4/9/2026 (the payslip numbered 0005)", dKey)
	}
	if len(store.holders) != 4 {
		t.Errorf("distinct payslips = %d, want 4 (three from run 1, one for D)", len(store.holders))
	}
}

// TestPayrollNumber_CounterTracksConsumption is the property stated directly, so
// a future change that reintroduces a block allocation fails on its own terms
// rather than only through a roster-shaped scenario.
func TestPayrollNumber_CounterTracksConsumption(t *testing.T) {
	seq := newCountingSeq()
	store := newPayrollStore()
	for _, id := range []int64{1, 2, 3, 4, 5} {
		store.addEmployee(id, "E")
	}
	svc := newSvcWithSeq(store, seq, time.FixedZone("WIB", 7*3600))

	if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); err != nil {
		t.Fatalf("run failed: %v", err)
	}
	// Five employees, so the counter must be at 5, not at 1.
	if got := seq.returned[len(seq.returned)-1]; got != 5 {
		t.Errorf("counter = %d after a five-employee run, want 5", got)
	}

	// A second identical run must not re-offer an ordinal the first run used,
	// even though ON CONFLICT would absorb the duplicates.
	seq.returned = nil
	store.addEmployee(6, "F")
	if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); err != nil {
		t.Fatalf("second run failed: %v", err)
	}
	first := map[int64]bool{}
	for _, n := range seq.returned {
		if first[n] {
			t.Errorf("the second run re-offered ordinal %d", n)
		}
		first[n] = true
	}
	lowest := seq.returned[0]
	if lowest != 6 {
		t.Errorf("second run started at ordinal %d, want 6: it must begin past everything the first run issued", lowest)
	}
}

// TestPayrollNumber_EmptyRosterConsumesNothing: the old code allocated before
// the loop, so a run with no active employees still burned a counter value.
// With the allocation moved inside the loop there is nothing to number, so
// nothing is consumed. No number can change, because no number is issued.
func TestPayrollNumber_EmptyRosterConsumesNothing(t *testing.T) {
	seq := newCountingSeq()
	store := newPayrollStore()
	svc := newSvcWithSeq(store, seq, time.FixedZone("WIB", 7*3600))

	out, err := svc.GeneratePayrolls(context.Background(), 9, 2026)
	if err != nil {
		t.Fatalf("run failed: %v", err)
	}
	if len(out) != 0 {
		t.Errorf("produced %d payrolls, want 0", len(out))
	}
	if len(seq.returned) != 0 {
		t.Errorf("allocator called %d times for an empty roster, want 0: it consumed ordinals it never issued",
			len(seq.returned))
	}
}

// TestPayrollNumber_DuplicateStillSurfacesAsErrDuplicate: the error path is
// unchanged by the fix. The repository maps 23505 and the service still returns
// ErrDuplicate, so a genuine collision anywhere else keeps behaving as before.
func TestPayrollNumber_DuplicateStillSurfacesAsErrDuplicate(t *testing.T) {
	seq := newCountingSeq()
	store := newPayrollStore()
	store.addEmployee(1, "A")
	// A row that already holds 0001 for this period, belonging to someone else.
	store.rows["99/9/2026"] = "PAY-202609-0001"
	store.holders["PAY-202609-0001"] = "99/9/2026"
	svc := newSvcWithSeq(store, seq, time.FixedZone("WIB", 7*3600))

	if _, err := svc.GeneratePayrolls(context.Background(), 9, 2026); !errors.Is(err, ErrDuplicate) {
		t.Errorf("err = %v, want ErrDuplicate", err)
	}
}

var _ = db.UpsertPayrollParams{}
