//go:build test && dev

package bookings

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"crussell/clock"
	"crussell/db"
	"crussell/handlers/scheduling"
	"crussell/mw"
	"crussell/testutils"
	"crussell/testutils/fixtures"
	"crussell/testutils/jwt"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
)

func makeReserveRequest(ctx context.Context, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
	var req *http.Request
	if body != nil {
		bodyBytes, _ := json.Marshal(body)
		req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
		req.Header.Set("Content-Type", "application/json")
	} else {
		req = httptest.NewRequest(method, path, nil)
	}

	// Add chi middleware stack for proper routing context
	r := chi.NewRouter()
	r.Use(middleware.RequestID)
	r.Use(middleware.ClientIPFromRemoteAddr)

	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}

	// Set up chi routing context and user context from token if present
	if token != "" {
		if info := extractUserFromTestJWT(token); info != nil {
			rctx := chi.NewRouteContext()
			ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
			ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
			ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
		}
	}
	req = req.WithContext(ctx)

	w := httptest.NewRecorder()

	handler := http.HandlerFunc(ReserveSlotHandler)
	r.Post("/api/bookings/reserve", handler)
	r.ServeHTTP(w, req)
	return w
}

func reserveTestToken(t *testing.T, userID, role string) string {
	t.Helper()
	return jwt.GenerateTestToken(userID, role)
}

// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
func TestReserveSlot_LoggedIn(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	userID, _ := fixtures.CreateTestUser(tx)
	token := reserveTestToken(t, userID, "verified_email")
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}
	startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)

	reqBody := ReserveSlotRequest{
		StartTime:  startTime,
		ServiceIDs: []string{serviceID},
	}

	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, token)

	if w.Code != http.StatusCreated {
		t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
	}

	var response ReserveSlotResponse
	if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
		t.Fatalf("failed to unmarshal response: %v", err)
	}

	if response.IsAnonymous {
		t.Error("expected is_anonymous=false for logged-in user")
	}
	if response.DurationMinutes == 0 {
		t.Error("expected non-zero duration")
	}
}

// TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation
// for the same user deletes the first one (max 1 per user).
func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	userID, _ := fixtures.CreateTestUser(tx)
	token := reserveTestToken(t, userID, "verified_email")
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}
	serviceIDs := []string{serviceID}

	// First reservation
	startTime1 := clock.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
	w1 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  startTime1,
		ServiceIDs: serviceIDs,
	}, token)
	if w1.Code != http.StatusCreated {
		t.Fatalf("first reservation failed: %d", w1.Code)
	}

	// Second reservation (should replace first)
	startTime2 := clock.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
	w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  startTime2,
		ServiceIDs: serviceIDs,
	}, token)
	if w2.Code != http.StatusCreated {
		t.Fatalf("second reservation failed: %d", w2.Code)
	}

	// Verify only one user reservation exists
	var count int
	err = tx.QueryRow(ctx, `
		SELECT COUNT(*) FROM time_blockers
		WHERE description LIKE 'RESERVATION:user:%' AND created_by = $1
	`, userID).Scan(&count)
	if err != nil {
		t.Fatalf("failed to count reservations: %v", err)
	}
	if count != 1 {
		t.Errorf("expected 1 reservation for user, got %d", count)
	}
}

// TestReserveSlot_Anonymous verifies anonymous users can reserve a slot.
func TestReserveSlot_Anonymous(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}
	serviceIDs := []string{serviceID}
	startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)

	reqBody := ReserveSlotRequest{
		StartTime:  startTime,
		ServiceIDs: serviceIDs,
	}

	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", reqBody, "")

	if w.Code != http.StatusCreated {
		t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
	}

	var response ReserveSlotResponse
	if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
		t.Fatalf("failed to unmarshal response: %v", err)
	}

	if !response.IsAnonymous {
		t.Error("expected is_anonymous=true for anonymous user")
	}
}

// TestReserveSlot_ValidationErrors verifies that missing or invalid
// fields result in 400 Bad Request.
func TestReserveSlot_ValidationErrors(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}
	serviceIDs := []string{serviceID}

	// Missing start_time
	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{
		"service_ids": serviceIDs,
	}, "")
	if w.Code != http.StatusBadRequest {
		t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
	}

	// Missing service_ids
	startTime := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
	w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", map[string]interface{}{
		"start_time": startTime,
	}, "")
	if w.Code != http.StatusBadRequest {
		t.Errorf("expected 400 for missing service_ids, got %d. body: %s", w.Code, w.Body.String())
	}

	// Past start_time
	w = makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  clock.Now().Add(-1 * time.Hour),
		ServiceIDs: serviceIDs,
	}, "")
	if w.Code != http.StatusBadRequest {
		t.Errorf("expected 400 for past start_time, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot
// that overlaps an existing booking returns 409 Conflict.
func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	// Create a fixture user and booking at the same time
	userID, _ := fixtures.CreateTestUser(tx)
	bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, deposit_required)
		VALUES ($1, $2, 'confirmed', false)
	`, userID, bookingStart)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}

	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  bookingStart,
		ServiceIDs: []string{serviceID},
	}, "")
	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked
// time slot returns 409 Conflict.
func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	blockerStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
	_, err = tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'Admin Blocked', NULL)
	`, blockerStart)
	if err != nil {
		t.Fatalf("failed to create blocker: %v", err)
	}

	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  blockerStart,
		ServiceIDs: []string{serviceID},
	}, "")
	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes
// anon reservations after 10 minutes and user reservations after 1 hour.
func TestReserveSlot_DualCleanup(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	// Create fixture users for user reservations
	user1ID, _ := fixtures.CreateTestUser(tx)
	user2ID, _ := fixtures.CreateTestUser(tx)

	// Create old anon reservation (15 min ago)
	_, err := tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
		VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
	`, clock.Now().Add(24*time.Hour), clock.Now().Add(-15*time.Minute))
	if err != nil {
		t.Fatalf("failed to create old anon reservation: %v", err)
	}

	// Create recent anon reservation (5 min ago) - should survive
	_, err = tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
		VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
	`, clock.Now().Add(48*time.Hour), clock.Now().Add(-5*time.Minute))
	if err != nil {
		t.Fatalf("failed to create recent anon reservation: %v", err)
	}

	// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
	_, err = tx.Exec(ctx, fmt.Sprintf(`
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
		VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
	`, user1ID), clock.Now().Add(72*time.Hour), clock.Now().Add(-45*time.Minute), user1ID)
	if err != nil {
		t.Fatalf("failed to create old user reservation: %v", err)
	}

	// Create very old user reservation (2 hours ago) - should be deleted
	_, err = tx.Exec(ctx, fmt.Sprintf(`
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
		VALUES ($1, 60, 'RESERVATION:user:%s:1234', $2, $3)
	`, user2ID), clock.Now().Add(96*time.Hour), clock.Now().Add(-2*time.Hour), user2ID)
	if err != nil {
		t.Fatalf("failed to create very old user reservation: %v", err)
	}

	// Run cleanup
	_, err = scheduling.CleanupOldReservations(ctx)
	if err != nil {
		t.Fatalf("CleanupOldReservations failed: %v", err)
	}

	// Verify old anon was deleted
	var anonOldCount int
	tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount)
	if anonOldCount > 0 {
		t.Error("expected old anon reservation to be deleted")
	}

	// Verify recent anon survived
	var anonRecentCount int
	tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount)
	if anonRecentCount != 1 {
		t.Error("expected recent anon reservation to survive")
	}

	// Verify 45-min user reservation survived
	var user45Count int
	tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user1ID)).Scan(&user45Count)
	if user45Count != 1 {
		t.Error("expected 45-min user reservation to survive")
	}

	// Verify 2hr user reservation was deleted
	var user2hrCount int
	tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE $1`, fmt.Sprintf("RESERVATION:user:%s:%%", user2ID)).Scan(&user2hrCount)
	if user2hrCount > 0 {
		t.Error("expected 2-hour user reservation to be deleted")
	}
}

// seedCustomWorkingHours replaces working_hours with the given schedule.
// DB convention: 0=Monday, 1=Tuesday, ..., 6=Sunday.
func seedCustomWorkingHours(t *testing.T, ctx context.Context, q db.Querier, hours []struct {
	weekday   int
	startTime string
	endTime   string
	isOpen    bool
}) {
	t.Helper()
	for _, h := range hours {
		_, err := q.Exec(ctx, `
			INSERT INTO working_hours (weekday, start_time, end_time, is_open)
			VALUES ($1, $2, $3, $4)
			ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
		`, h.weekday, h.startTime, h.endTime, h.isOpen)
		if err != nil {
			t.Fatalf("failed to seed working hours for weekday %d: %v", h.weekday, err)
		}
	}
}

// nextWeekday returns the next occurrence of the given weekday (0=Sunday..6=Saturday)
// in the given location, at least 2 days from now to avoid "in the past" rejections.
func nextWeekday(weekday time.Weekday) time.Time {
	// Use clock.Now() (UTC) so the returned time is consistent with
	// handler comparisons that use clock.Now() — avoids BST/GMT drift
	// when the handler checks deposit advance windows or working hours.
	now := clock.Now()
	daysAhead := int(weekday) - int(now.Weekday())
	if daysAhead <= 0 {
		daysAhead += 7
	}
	if daysAhead < 2 {
		daysAhead += 7
	}
	next := now.AddDate(0, 0, daysAhead)
	return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, time.UTC)
}

// TestReserveSlot_WeekdayConversion verifies that Go's time.Weekday (0=Sunday)
// is correctly mapped to the DB's weekday convention (0=Monday).
func TestReserveSlot_WeekdayConversion(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	hours := []struct {
		weekday   int
		startTime string
		endTime   string
		isOpen    bool
	}{
		{0, "09:00", "17:00", true},
		{1, "09:00", "17:00", true},
		{2, "09:00", "17:00", true},
		{3, "09:00", "17:00", true},
		{4, "09:00", "17:00", true},
		{5, "10:00", "14:00", true},
		{6, "00:00", "00:00", false},
	}

	seedCustomWorkingHours(t, ctx, tx, hours)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	monday := nextWeekday(time.Monday)

	tests := []struct {
		name       string
		startTime  time.Time
		expectCode int
	}{
		{"Monday 10:00", monday.Add(10 * time.Hour), http.StatusCreated},
		{"Tuesday 10:00", monday.AddDate(0, 0, 1).Add(10 * time.Hour), http.StatusCreated},
		{"Wednesday 10:00", monday.AddDate(0, 0, 2).Add(10 * time.Hour), http.StatusCreated},
		{"Thursday 10:00", monday.AddDate(0, 0, 3).Add(10 * time.Hour), http.StatusCreated},
		{"Friday 10:00", monday.AddDate(0, 0, 4).Add(10 * time.Hour), http.StatusCreated},
		{"Saturday 11:00", monday.AddDate(0, 0, 5).Add(11 * time.Hour), http.StatusCreated},
		{"Sunday 11:00", monday.AddDate(0, 0, 6).Add(11 * time.Hour), http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
				StartTime:  tt.startTime,
				ServiceIDs: []string{serviceID},
			}, "")

			if w.Code != tt.expectCode {
				t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
			}
		})
	}
}

// TestReserveSlot_ClosingHoursValidation verifies that bookings extending
// past closing time are rejected, and that the correct day's closing time
// is used (not the wrong day due to weekday mismatch).
func TestReserveSlot_ClosingHoursValidation(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	hours := []struct {
		weekday   int
		startTime string
		endTime   string
		isOpen    bool
	}{
		{0, "09:00", "17:00", true},
		{1, "09:00", "17:00", true},
		{2, "09:00", "17:00", true},
		{3, "12:00", "20:00", true},
		{4, "09:00", "17:00", true},
		{5, "10:00", "14:00", true},
		{6, "00:00", "00:00", false},
	}

	seedCustomWorkingHours(t, ctx, tx, hours)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	monday := nextWeekday(time.Monday)
	thursday := monday.AddDate(0, 0, 3)

	tests := []struct {
		name       string
		startTime  time.Time
		expectCode int
	}{
		{"Thursday 17:30 (within 20:00 close)", thursday.Add(17*time.Hour + 30*time.Minute), http.StatusCreated},
		{"Thursday 19:30 (past 20:00 close)", thursday.Add(19*time.Hour + 30*time.Minute), http.StatusBadRequest},
		{"Monday 16:30 (past 17:00 close)", monday.Add(16*time.Hour + 30*time.Minute), http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
				StartTime:  tt.startTime,
				ServiceIDs: []string{serviceID},
			}, "")

			if w.Code != tt.expectCode {
				t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
			}
		})
	}
}

// TestReserveSlot_UTCtoLondonConversion verifies that the UTC-to-London
// conversion correctly validates closing hours against the London wall-clock.
// Test times are in UTC; the handler converts to London time for comparison.
func TestReserveSlot_UTCtoLondonConversion(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	hours := []struct {
		weekday   int
		startTime string
		endTime   string
		isOpen    bool
	}{
		{0, "09:00", "17:00", true},
		{1, "09:00", "17:00", true},
		{2, "09:00", "17:00", true},
		{3, "12:00", "20:00", true},
		{4, "09:00", "17:00", true},
		{5, "10:00", "14:00", true},
		{6, "00:00", "00:00", false},
	}

	seedCustomWorkingHours(t, ctx, tx, hours)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	thursday := nextWeekday(time.Thursday)
	thursday1730BST := thursday.Add(17*time.Hour + 30*time.Minute)
	thursday1930BST := thursday.Add(19*time.Hour + 30*time.Minute)

	tests := []struct {
		name       string
		startTime  time.Time
		expectCode int
	}{
		{"17:30 UTC = 18:30 BST Thursday (within 20:00 close)", thursday1730BST, http.StatusCreated},
		{"19:30 UTC = 20:30 BST Thursday (past 20:00 close)", thursday1930BST, http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
				StartTime:  tt.startTime,
				ServiceIDs: []string{serviceID},
			}, "")

			if w.Code != tt.expectCode {
				t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
			}
		})
	}
}

// TestReserveSlot_DifferentClosingPerDay verifies that each day's closing
// time is used independently — a late-booking on a late-closing day should
// succeed while the same time on an early-closing day should fail.
// TestReserveSlot_BlockedByExistingBooking_LoggedIn verifies that a logged-in
// user's reservation is rejected with 409 when the slot overlaps an existing
// booking. This tests the hasAuth transaction path with FOR UPDATE locking.
func TestReserveSlot_BlockedByExistingBooking_LoggedIn(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	// Create an existing booking at the same time slot
	bookingStart := clock.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, deposit_required)
		VALUES ($1, $2, 'confirmed', false)
	`, userID, bookingStart)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}

	// Try to reserve the same slot as logged-in user — goes through hasAuth tx path
	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  bookingStart,
		ServiceIDs: []string{serviceID},
	}, token)
	if w.Code != http.StatusConflict {
		t.Errorf("logged-in overlap: expected 409, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestReserveSlot_AdjacentBooking_Allowed verifies that reserving a slot
// adjacent to (but not overlapping) an existing booking is allowed for
// both logged-in and anonymous users. This is the negative test for the
// overlap check — the FOR UPDATE locking should not block non-conflicting slots.
func TestReserveSlot_AdjacentBooking_Allowed(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	// Use a far-future weekday so closing-time and deposit checks pass
	thursday := nextWeekday(time.Thursday).Add(21 * 24 * time.Hour)
	bookingStart := time.Date(thursday.Year(), thursday.Month(), thursday.Day(), 10, 0, 0, 0, time.UTC)
	svcDuration := durationMinutes(t, ctx, tx, serviceID)
	bookingEnd := bookingStart.Add(time.Duration(svcDuration) * time.Minute)

	// Create an existing booking at [bookingStart, bookingEnd)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, deposit_required)
		VALUES ($1, $2, 'confirmed', false)
	`, userID, bookingStart)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}

	// Try to reserve a slot that starts exactly when the existing booking ends
	// This is ADJACENT (no overlap) — must be allowed.
	adjacentStart := bookingEnd
	adjacentEnd := adjacentStart.Add(time.Duration(svcDuration) * time.Minute)
	_ = adjacentEnd // for documentation

	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  adjacentStart,
		ServiceIDs: []string{serviceID},
	}, token)
	if w.Code != http.StatusCreated {
		t.Errorf("logged-in adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w.Code, w.Body.String())
	}

	// Also test anonymous user with the same adjacent slot
	// Use a fresh time slot (don't reuse the now-booked one, since an anon
	// reservation is a time_blocker not a booking, and doesn't conflict)
	anonStart := adjacentStart.Add(2 * time.Hour)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, deposit_required)
		VALUES ($1, $2, 'confirmed', false)
	`, userID, anonStart)
	if err != nil {
		t.Fatalf("failed to create booking for anon test: %v", err)
	}

	anonAdjacent := anonStart.Add(time.Duration(svcDuration) * time.Minute)
	w2 := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  anonAdjacent,
		ServiceIDs: []string{serviceID},
	}, "")
	if w2.Code != http.StatusCreated {
		t.Errorf("anon adjacent: expected 201 (adjacent, no overlap), got %d. body: %s", w2.Code, w2.Body.String())
	}
}

func TestReserveSlot_DifferentClosingPerDay(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	hours := []struct {
		weekday   int
		startTime string
		endTime   string
		isOpen    bool
	}{
		{0, "09:00", "17:00", true},
		{1, "09:00", "17:00", true},
		{2, "12:00", "17:00", true},
		{3, "12:00", "20:00", true},
		{4, "09:00", "17:00", true},
		{5, "10:00", "14:00", true},
		{6, "00:00", "00:00", false},
	}

	seedCustomWorkingHours(t, ctx, tx, hours)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	wednesday := nextWeekday(time.Wednesday)
	thursday := wednesday.AddDate(0, 0, 1)

	tests := []struct {
		name       string
		startTime  time.Time
		expectCode int
	}{
		{"Wednesday 18:00 (closes 17:00)", wednesday.Add(18 * time.Hour), http.StatusBadRequest},
		{"Thursday 18:00 (closes 20:00)", thursday.Add(18 * time.Hour), http.StatusCreated},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
				StartTime:  tt.startTime,
				ServiceIDs: []string{serviceID},
			}, "")

			if w.Code != tt.expectCode {
				t.Errorf("%s: expected status %d, got %d. body: %s", tt.name, tt.expectCode, w.Code, w.Body.String())
			}
		})
	}
}

// TestReserveSlot_PendingRelease_DoesNotBlock verifies that a pending_release
// booking does NOT block the reserve endpoint. pending_release bookings are
// evictable — eviction happens at creation time (CreateBookingHandler), not
// during the temporary reservation step.
func TestReserveSlot_PendingRelease_DoesNotBlock(t *testing.T) {
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create test service: %v", err)
	}

	future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)

	// Create a pending_release booking at this time slot
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, deposit_required)
		VALUES ($1, $2, 'pending_release', false)
	`, userID, future)
	if err != nil {
		t.Fatalf("failed to create pending_release booking: %v", err)
	}

	// Reserve the same slot — should succeed because pending_release
	// does not block reservations (eviction happens at creation time).
	w := makeReserveRequest(ctx, "POST", "/api/bookings/reserve", ReserveSlotRequest{
		StartTime:  future,
		ServiceIDs: []string{serviceID},
	}, token)

	if w.Code == http.StatusConflict {
		t.Errorf("pending_release should NOT block reservation – it is evictable at creation time, got 409")
	}
	if w.Code != http.StatusCreated {
		t.Errorf("expected 201 for slot with only pending_release overlap, got %d. body: %s", w.Code, w.Body.String())
	}
}
