package hr

import (
	"context"
	"errors"
	"net/http"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/jackc/pgx/v5/pgconn"

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

// --- fakes that model the real allocator -------------------------------------

// bucketSeq models pkg/seq.Next faithfully: the counter lives in a bucket keyed
// on (prefix, doc_date) and RESETS when the date changes. That reset is the
// defect under test, so a fake that ignored the date would hide it.
type bucketSeq struct {
	mu      sync.Mutex
	buckets map[string]int64
	calls   []time.Time // the bucket dates the service asked for
}

// newBucketSeqAt models the state migration 000106 leaves behind: the anchor
// bucket already above the seeded rows, so EMP-001..EMP-007 are not reissued.
// A per-day bucket has no such starting point - it opens empty at 1.
func newBucketSeqAt(start int64) *bucketSeq {
	return &bucketSeq{buckets: map[string]int64{"emp|2000-01-01": start - 1}}
}

func newBucketSeq() *bucketSeq { return newBucketSeqAt(1) }

func (b *bucketSeq) Sequence(_ context.Context, prefix string, date time.Time, _ any) (int64, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	key := prefix + "|" + date.UTC().Format("2006-01-02")
	b.buckets[key]++
	b.calls = append(b.calls, date)
	return b.buckets[key], nil
}

// uniqueStore rejects a duplicate employee_number the way the unique constraint
// does, so a collision surfaces as an error instead of a silently-duplicated row.
type uniqueStore struct {
	*fakeStore
	mu sync.Mutex
	// taken is the set of employee numbers already present, seeded by the test
	// to stand in for the rows seed.sql created outside the allocator.
	taken map[string]bool
	byNum map[string]int64
	// rejectNik, when set, refuses on employees_nik_key instead, so a test can
	// drive the caller-supplied-duplicate branch.
	rejectNik bool
	nextID    int64
}

func newUniqueStore() *uniqueStore {
	return &uniqueStore{
		fakeStore: newFakeStore(),
		taken:     map[string]bool{},
		byNum:     map[string]int64{},
		nextID:    100,
	}
}

func (s *uniqueStore) CreateEmployee(_ context.Context, p db.CreateEmployeeParams) (db.CreateEmployeeRow, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.rejectNik && p.Nik.String != "" {
		// Through the repository's mapping, as the real path does.
		return db.CreateEmployeeRow{}, mapEmployeeInsertErr(&pgconn.PgError{
			Code:           "23505",
			Message:        `duplicate key value violates unique constraint "employees_nik_key"`,
			ConstraintName: "employees_nik_key",
		})
	}
	if s.taken[p.EmployeeNumber] {
		// The shape the driver returns for employees_employee_number_key. The
		// real Repository runs this through mapEmployeeInsertErr before the
		// error reaches the handler, so the fake must too - otherwise the test
		// would be asserting the shared boundary's behaviour for an error the
		// repository never lets through.
		return db.CreateEmployeeRow{}, mapEmployeeInsertErr(&pgconn.PgError{
			Severity:       "ERROR",
			Code:           "23505",
			Message:        `duplicate key value violates unique constraint "employees_employee_number_key"`,
			ConstraintName: "employees_employee_number_key",
			TableName:      "employees",
		})
	}
	s.taken[p.EmployeeNumber] = true
	s.nextID++
	s.byNum[p.EmployeeNumber] = s.nextID
	return db.CreateEmployeeRow{ID: s.nextID, EmployeeNumber: p.EmployeeNumber, Name: p.Name}, nil
}

// TestEmployeeNumber_DoesNotResetAcrossDays is the regression test for the live
// bug. The shape is a bucket reset, not a race, so the test advances the clock
// across midnight against an allocator that really does reset per day.
//
// Against the old code (bucket date = the current day) this fails on the second
// create: the fresh day restarts the counter at 1, EMP-001 is allocated again,
// and the store rejects it as a duplicate.
func TestEmployeeNumber_DoesNotResetAcrossDays(t *testing.T) {
	// seed.sql occupies EMP-001..EMP-007 and 000106 lifts the counter to 7.
	seq := newBucketSeqAt(8)
	store := newUniqueStore()
	loc := time.FixedZone("WIB", 7*3600)

	// A clock the test can move. The allocator is date-bucketed, so the only
	// way to exercise a reset is to cross a day boundary.
	day := time.Date(2026, 3, 10, 9, 0, 0, 0, loc)
	clock := func() time.Time { return day }
	svc := newSvcWithSeq(store, seq, loc)
	svc.now = clock

	// The seeded rows exist, so EMP-001 and EMP-002 are genuinely taken.
	store.taken["EMP-001"] = true
	store.taken["EMP-002"] = true

	e1, err := svc.CreateEmployee(context.Background(), EmployeeInput{
		Name: "Rina", NIK: "1271050505920999",
	})
	if err != nil {
		t.Fatalf("day 1 create failed: %v", err)
	}
	if e1.EmployeeNumber == "EMP-001" || e1.EmployeeNumber == "EMP-002" {
		t.Fatalf("day 1 allocated %s, which seed.sql already occupies", e1.EmployeeNumber)
	}

	// Midnight passes. The next create must not restart the counter.
	day = day.AddDate(0, 0, 1)

	e2, err := svc.CreateEmployee(context.Background(), EmployeeInput{
		Name: "Sari", NIK: "1271050505920888",
	})
	if err != nil {
		t.Fatalf("a create on the NEXT DAY collided, which is the live bug: %v", err)
	}
	if e1.EmployeeNumber == e2.EmployeeNumber {
		t.Errorf("day 1 and day 2 were both allocated %s: the counter reset at midnight", e1.EmployeeNumber)
	}
	if len(store.byNum) != 2 {
		t.Errorf("distinct employee numbers = %d, want 2", len(store.byNum))
	}

	// The allocator must have been asked for one bucket, twice, not a bucket
	// per day. With time.Now() the two calls carry two different dates.
	if len(seq.calls) != 2 {
		t.Fatalf("allocator called %d times, want 2", len(seq.calls))
	}
	if got := seq.calls[0].UTC().Format("2006-01-02"); got != "2000-01-01" {
		t.Errorf("bucket date = %s, want the fixed anchor 2000-01-01 so the counter never resets", got)
	}
	if len(store.byNum) != 2 {
		t.Errorf("distinct employee numbers = %d, want 2", len(store.byNum))
	}
}

// TestEmployeeNumber_ConcurrentCreatesAreDistinct actually races. The
// allocator is contractually atomic, so this should pass both before and after
// the fix - it is here to prove the fix did not introduce a race where the
// counter is now shared across days rather than isolated per day, which is a
// real hazard of moving to a single bucket.
func TestEmployeeNumber_ConcurrentCreatesAreDistinct(t *testing.T) {
	seq := newBucketSeq()
	store := newUniqueStore()
	svc := newSvcWithSeq(store, seq, time.FixedZone("WIB", 7*3600))

	const workers = 8
	// Barrier: every goroutine is released at once, so the creates overlap
	// rather than running one after another. Without this the test would pass
	// against a broken allocator simply because nothing collided.
	var ready sync.WaitGroup
	var go_ sync.WaitGroup
	ready.Add(workers)
	go_.Add(workers)
	start := make(chan struct{})

	errs := make([]error, workers)
	for i := 0; i < workers; i++ {
		go func(i int) {
			defer go_.Done()
			ready.Done()
			<-start
			_, errs[i] = svc.CreateEmployee(context.Background(), EmployeeInput{
				Name: "Wira", NIK: "127105050592" + string(rune('0'+i)),
			})
		}(i)
	}
	ready.Wait()
	close(start)
	go_.Wait()

	for i, err := range errs {
		if err != nil {
			t.Errorf("worker %d failed: %v", i, err)
		}
	}
	if len(store.byNum) != workers {
		t.Errorf("distinct employee numbers = %d, want %d (a collision means the counter handed out a duplicate)", len(store.byNum), workers)
	}
}

// --- the two 23505 cases must not say the same thing -----------------------

func pgErr(code, constraint string) *pgconn.PgError {
	return &pgconn.PgError{Code: code, ConstraintName: constraint, TableName: "employees"}
}

// TestMapEmployeeInsertErr_SplitsTheTwoUniques is the mapping under test: the
// server can tell a caller-supplied duplicate from its own allocation
// collision, and the two must not produce the same error.
func TestMapEmployeeInsertErr_SplitsTheTwoUniques(t *testing.T) {
	t.Run("server-generated number is a server fault", func(t *testing.T) {
		err := mapEmployeeInsertErr(pgErr("23505", "employees_employee_number_key"))
		if !errors.Is(err, ErrNumberAllocation) {
			t.Errorf("got %v, want ErrNumberAllocation: the caller sent no employee number, so this is not their duplicate", err)
		}
		if errors.Is(err, ErrDuplicate) {
			t.Error("a server-side allocation collision must not be reported as ErrDuplicate, which the user is told to fix")
		}
	})
	t.Run("caller-supplied NIK is a genuine duplicate", func(t *testing.T) {
		err := mapEmployeeInsertErr(pgErr("23505", "employees_nik_key"))
		if !errors.Is(err, ErrDuplicate) {
			t.Errorf("got %v, want ErrDuplicate: the caller supplied the NIK and can change it", err)
		}
		if errors.Is(err, ErrNumberAllocation) {
			t.Error("a caller-supplied duplicate must not be reported as a server fault")
		}
	})
	t.Run("other errors pass through", func(t *testing.T) {
		if err := mapEmployeeInsertErr(nil); err != nil {
			t.Errorf("nil -> %v", err)
		}
		sentinel := errors.New("connection reset")
		if err := mapEmployeeInsertErr(sentinel); !errors.Is(err, sentinel) {
			t.Errorf("got %v, want the original error", err)
		}
	})
}

// TestCreateEmployee_AllocationCollisionIsNotBlamedOnTheCaller is the end-to-end
// version: the live response for the reported request. It must be a 5xx with a
// safe message, and it must NOT be the 409 "duplicate value" that tells an HR
// clerk to go and find a duplicate employee they never created.
func TestCreateEmployee_AllocationCollisionIsNotBlamedOnTheCaller(t *testing.T) {
	store := newUniqueStore()
	// The collision: the allocator hands out a number seed.sql already occupies.
	store.taken["EMP-001"] = true

	res := postCreateEmployeeTo(t, store, newBucketSeq(),
		`{"name":"Rina","nik":"1271050505920999"}`)

	if res.Code < 500 || res.Code > 599 {
		t.Fatalf("status = %d, want 5xx: a server-side allocation collision is our bug, not the caller's", res.Code)
	}
	_, message := errorEnvelope(t, res)
	if strings.Contains(strings.ToLower(message), "duplicate") {
		t.Errorf("message = %q, must not tell the caller they supplied a duplicate they never sent", message)
	}
	if strings.Contains(strings.ToLower(message), "already in use") {
		t.Errorf("message = %q, must not claim the caller supplied the value", message)
	}
	if strings.TrimSpace(message) == "" {
		t.Error("message is empty")
	}
	assertNoSchemaLeak(t, res)
}

// TestCreateEmployee_DuplicateNIKStaysActionable: the genuine-duplicate case
// must keep a message the user can act on. Narrowing the 23505 mapping for the
// server-side case must not have cost the caller-side case its usefulness.
func TestCreateEmployee_DuplicateNIKStaysActionable(t *testing.T) {
	store := newUniqueStore()
	// Refuse on employees_nik_key, as the real constraint would.
	store.rejectNik = true
	res := postCreateEmployeeTo(t, store, newBucketSeq(),
		`{"name":"Rina","nik":"1271010101800001"}`)

	if res.Code != http.StatusConflict {
		t.Errorf("status = %d, want 409: a repeated NIK is a business conflict the caller can resolve", res.Code)
	}
	_, message := errorEnvelope(t, res)
	if strings.TrimSpace(message) == "" {
		t.Fatal("message is empty: a duplicate NIK must tell the user what to do")
	}
	assertNoSchemaLeak(t, res)
}

// TestGenericDuplicateMessage_TrueForBothCases: the shared 23505 message is
// reached only by modules that have not split their own case, so it must be
// true whether the caller or the server supplied the value. This asserts the
// wording property directly, independent of any particular module.
func TestGenericDuplicateMessage_TrueForBothCases(t *testing.T) {
	msg := httpresponseMessageFor(t, pgErr("23505", "employees_employee_number_key"))
	for _, forbidden := range []string{"already in use", "you already", "you entered", "that value is already"} {
		if strings.Contains(strings.ToLower(msg), forbidden) {
			t.Errorf("23505 message %q contains %q, which asserts the CALLER supplied the duplicate; the employee number is allocated by the server", msg, forbidden)
		}
	}
	if !strings.Contains(strings.ToLower(msg), "already exists") {
		t.Errorf("23505 message = %q, want it to state the fact that is true in both cases", msg)
	}
	if strings.TrimSpace(msg) == "" {
		t.Error("23505 message is empty")
	}
}
