//go:build test && dev

package bookings

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

	"crussell/clock"
	"crussell/db"
	"crussell/mw"
	"crussell/testutils"
	"crussell/testutils/fixtures"
	"crussell/testutils/jwt"
	"github.com/go-chi/chi/v5"
)

// ============================================================================
// Helpers
// ============================================================================

// weekdayTime returns a far-future time on the given weekday at hour:00 UTC.
// Ensures at least 2 weeks out so all time-window checks (deposits, advance)
// pass without interference.
func weekdayTime(weekday time.Weekday, hour int) time.Time {
	now := clock.Now().UTC()
	daysAhead := int(weekday) - int(now.Weekday())
	if daysAhead <= 0 {
		daysAhead += 7
	}
	// Push far enough forward that advance-window / deposit checks don't interfere
	daysAhead += 21
	result := now.AddDate(0, 0, daysAhead)
	return time.Date(result.Year(), result.Month(), result.Day(), hour, 0, 0, 0, time.UTC)
}

// durationMinutes returns the duration_minutes for a given service ID from the tx.
func durationMinutes(t *testing.T, ctx context.Context, tx db.Querier, serviceID string) int {
	t.Helper()
	var d int
	if err := tx.QueryRow(ctx, "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&d); err != nil {
		t.Fatalf("failed to get service duration: %v", err)
	}
	return d
}

// ============================================================================
// AdminCreateBookingForUserHandler — overlap detection
// ============================================================================

// TestAdminCreateBooking_OverlapWithConfirmed verifies that creating a booking
// at a time that overlaps an existing confirmed booking returns 409.
func TestAdminCreateBooking_OverlapWithConfirmed(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create an existing confirmed booking
	existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID)
	if err != nil {
		t.Fatalf("failed to confirm existing booking: %v", err)
	}

	// Try to create a new booking overlapping the existing one (starts 15 min
	// after the existing booking starts but before it ends).
	overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute)

	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  overlapTime,
		ServiceIDs: []string{serviceID},
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify the existing booking was NOT evicted (no pending_release eviction
	// since the existing booking is confirmed, not pending_release).
	var status string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingID).Scan(&status)
	if err != nil {
		t.Fatalf("failed to query existing booking: %v", err)
	}
	if status != "confirmed" {
		t.Errorf("expected existing booking to remain 'confirmed', got %q", status)
	}
}

// TestAdminCreateBooking_AdjacentBooking_NoOverlap verifies that creating a
// booking at the exact end of an existing booking (adjacent, no gap) is allowed.
func TestAdminCreateBooking_AdjacentBooking_NoOverlap(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create an existing confirmed booking [10:00, 10:00+dur)
	existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID)
	if err != nil {
		t.Fatalf("failed to confirm existing booking: %v", err)
	}

	// Create a new booking starting exactly when the existing one ends (adjacent,
	// no overlap).
	adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute)

	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  adjacentTime,
		ServiceIDs: []string{serviceID},
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		t.Errorf("expected 200/201 for adjacent (non-overlapping) booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminCreateBooking_ExactSameStartTime verifies that creating a booking
// at the exact same start time as an existing booking is rejected.
func TestAdminCreateBooking_ExactSameStartTime(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	baseTime := weekdayTime(time.Wednesday, 10)

	existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID)
	if err != nil {
		t.Fatalf("failed to confirm existing booking: %v", err)
	}

	// Same exact start time
	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  baseTime,
		ServiceIDs: []string{serviceID},
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for same start time, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// EditBookingHandler - user-facing edit overlap detection
// ============================================================================

// TestEditBooking_OverlapWithExisting verifies that editing a booking to an
// overlapping time slot returns 409.
func TestEditBooking_OverlapWithExisting(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	// Disable deposits for this user so the handler doesn't check deposit limits
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}

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

	baseTime := weekdayTime(time.Wednesday, 10)
	token := jwt.GenerateUserToken(userID)

	// Create booking A (the one we're editing) at [10:00, 10:00+dur)
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Create booking B at [12:00, 12:00+dur) — far enough away to not interfere initially
	farTime := baseTime.Add(2 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	// Now edit booking A to a time that overlaps booking B.
	// Move it to [11:30, 11:30+dur) — booking B is at [12:00, 12:00+dur), so
	// 11:30+dur > 12:00 means overlap.
	overlapTime := farTime.Add(-time.Duration(dur/2) * time.Minute)

	handler := http.HandlerFunc(EditBookingHandler)
	w := makeRequest(handler, "PUT", "/api/bookings/"+bookingA, map[string]interface{}{
		"start_time":  overlapTime.Format(time.RFC3339),
		"service_ids": []string{serviceID},
	}, token, ctx)

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping edit, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify booking A was NOT updated (still at original time)
	var actualStart time.Time
	err = tx.QueryRow(ctx, "SELECT start_time FROM bookings WHERE id = $1", bookingA).Scan(&actualStart)
	if err != nil {
		t.Fatalf("failed to query booking A: %v", err)
	}
	if !actualStart.Equal(baseTime) {
		t.Errorf("expected booking A start_time to remain %v, got %v", baseTime, actualStart)
	}
}

// TestEditBooking_NonOverlappingEdit_Succeeds verifies that editing a booking
// to a non-overlapping time succeeds.
func TestEditBooking_NonOverlappingEdit_Succeeds(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}

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

	baseTime := weekdayTime(time.Wednesday, 10)
	newTime := baseTime.Add(24 * time.Hour) // Next day, far from any conflict
	token := jwt.GenerateUserToken(userID)

	bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
	if err != nil {
		t.Fatalf("failed to confirm booking: %v", err)
	}

	_ = dur

	handler := http.HandlerFunc(EditBookingHandler)
	w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, map[string]interface{}{
		"start_time":  newTime.Format(time.RFC3339),
		"service_ids": []string{serviceID},
	}, token, ctx)

	if w.Code != http.StatusOK {
		t.Errorf("expected 200 for non-overlapping edit, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// RequestEditHandler — auto-approve path overlap detection
// ============================================================================

// TestRequestEditHandler_AutoApprove_OverlapDetected verifies that the
// auto-approve path rejects a request that would create an overlap.
// Requests with new_start_time and >48h to the event trigger auto-approval
// which runs the overlap check inline.
func TestRequestEditHandler_AutoApprove_OverlapDetected(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)
	token := jwt.GenerateUserToken(userID)

	// Booking A — user's existing confirmed booking at [10:00, 10:00+dur)
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — at a far-away time (so auto-approve triggers when we set
	// new_start_time on an existing confirmed booking >48h out)
	bookingBTime := baseTime.Add(48 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	// Submit edit request with a new_start_time that overlaps A.
	// Since the edit request is >48h before the booking event, the auto-approve
	// path runs the overlap check and should reject with 409.
	overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute)

	handler := http.HandlerFunc(RequestEditHandler)
	w := makeRequest(handler, "POST", "/api/bookings/"+bookingB+"/edit-request", map[string]interface{}{
		"new_start_time": overlapTime.Format(time.RFC3339),
	}, token, ctx)

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping auto-approve, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// UpdateBookingServicesHandler — extending duration into existing booking
// ============================================================================

// TestUpdateBookingServices_ExtendOverlapsExisting verifies that extending a
// booking's duration (by adding a second service) into an existing booking's
// slot returns 409.
func TestUpdateBookingServices_ExtendOverlapsExisting(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Booking A — starts at 10:00, duration = dur (ends at 10:00+dur)
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — starts exactly when A ends (adjacent, no overlap initially)
	adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, adjacentTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	// Create a second service with short duration (30 min) that, when added to
	// A, would make A extend INTO B's slot.
	shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30)
	if err != nil {
		t.Fatalf("failed to create short service: %v", err)
	}

	// Try to add the second service to booking A — this extends A's duration
	// to dur+30, which exceeds the gap to booking B.
	w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{
		"service_ids": []string{serviceID, shortSvcID},
	}, func(baseCtx context.Context) context.Context {
		baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
		baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
		return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
	})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for extending into existing booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestUpdateBookingServices_ExtendNoOverlap_Succeeds verifies that extending a
// booking's duration without overlapping another booking succeeds.
func TestUpdateBookingServices_ExtendNoOverlap_Succeeds(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Booking A — starts at 10:00
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — starts 3 hours later (plenty of room)
	farTime := baseTime.Add(3 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	_ = dur

	// Create a second service with 30 min duration — A would be dur+30,
	// which still leaves plenty of gap before B.
	shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30)
	if err != nil {
		t.Fatalf("failed to create short service: %v", err)
	}

	w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{
		"service_ids": []string{serviceID, shortSvcID},
	}, func(baseCtx context.Context) context.Context {
		baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
		baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
		return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
	})

	if w.Code != http.StatusOK {
		t.Errorf("expected 200 for extending without overlap, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// ReserveSlotHandler — overlap detection (existing test was checking for
// regression, this strengthens coverage)
// ============================================================================

// TestReserveSlot_OverlapWithExistingBooking confirms that the reserve endpoint
// returns 409 when the requested slot overlaps a confirmed booking.
func TestReserveSlot_OverlapWithExistingBooking(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

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

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

	baseTime := weekdayTime(time.Wednesday, 10)

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

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

// ============================================================================
// AdminReserveSlotHandler — overlap detection
// ============================================================================

// TestAdminReserveSlot_OverlapWithExistingBooking verifies that admin reserve
// returns 409 when the slot overlaps a confirmed booking.
func TestAdminReserveSlot_OverlapWithExistingBooking(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

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

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

	baseTime := weekdayTime(time.Wednesday, 10)

	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status)
		VALUES ($1, $2, 'confirmed')
	`, userID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}

	w := serveChiHandler(AdminReserveSlotHandler, "POST", "/", "/", AdminReserveSlotRequest{
		StartTime:       baseTime,
		ServiceIDs:      []string{serviceID},
		ReservationType: "callin",
	}, func(baseCtx context.Context) context.Context {
		baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
		baseCtx = context.WithValue(baseCtx, mw.UserIDKey, userID)
		return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
	})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping admin reserve, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// AdminRescheduleBookingHandler — overlap detection
// ============================================================================

// TestAdminRescheduleBooking_OverlapWithConfirmed verifies that rescheduling
// a booking to an overlapping time returns 409.
func TestAdminRescheduleBooking_OverlapWithConfirmed(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Booking A — occupies [10:00, 10:00+dur)
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — later slot, will be rescheduled INTO A's slot
	bookingBTime := baseTime.Add(48 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	// Try to reschedule B into A's slot (overlap)
	overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute)

	body := map[string]interface{}{
		"start_time": overlapTime.Format(time.RFC3339),
	}

	w := serveChiHandler(AdminRescheduleBookingHandler, "PUT", "/"+bookingB+"/reschedule", "/{id}/reschedule", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for reschedule overlapping booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// ConfirmBookingHandler — overlap detection
// ============================================================================

// TestConfirmBooking_OverlapWithConfirmed verifies that confirming a booking
// that would overlap an existing confirmed booking returns 409.
func TestConfirmBooking_OverlapWithConfirmed(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Booking A — confirmed, occupies [10:00, 10:00+dur)
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — pending, at the same time (would overlap if confirmed)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime.Add(15*time.Minute))
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to set booking B to pending: %v", err)
	}

	_ = dur

	// Confirm booking B — should be rejected because it would overlap A
	w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+bookingB+"/confirm", "/{id}/confirm", map[string]interface{}{},
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for confirm overlapping booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestConfirmBooking_NoOverlap_Succeeds verifies that confirming a non-overlapping
// pending booking succeeds.
func TestConfirmBooking_NoOverlap_Succeeds(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Existing confirmed booking
	existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID)
	if err != nil {
		t.Fatalf("failed to confirm existing booking: %v", err)
	}

	// Pending booking at a non-overlapping time (adjacent, exactly when A ends)
	pendingStart := baseTime.Add(time.Duration(dur) * time.Minute)
	pendingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingStart)
	if err != nil {
		t.Fatalf("failed to create pending booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending' WHERE id = $1", pendingID)
	if err != nil {
		t.Fatalf("failed to set pending: %v", err)
	}

	w := serveChiHandler(ConfirmBookingHandler, "POST", "/"+pendingID+"/confirm", "/{id}/confirm", map[string]interface{}{},
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusOK {
		t.Errorf("expected 200 for confirming non-overlapping booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// AdminApproveEditRequestHandler — overlap at approval time
// ============================================================================

// TestAdminApproveEditRequest_OverlapWithBooking is already tested in
// edit_requests_test.go:1607. This test re-verifies the same scenario with
// the updated handler to guard against regression.
func TestAdminApproveEditRequest_OverlapWithBooking_Regression(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	// Use <48h from now so RequestEditHandler does NOT auto-approve
	nearTime := clock.Now().Add(40 * time.Hour)
	nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), nearTime.Hour(), 0, 0, 0, nearTime.Location())

	token := jwt.GenerateUserToken(userID)

	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	bookingBTime := nearTime.Add(2 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bookingBTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to confirm booking B: %v", err)
	}

	overlapTime := nearTime.Add(time.Duration(dur/2) * time.Minute)
	handler := http.HandlerFunc(RequestEditHandler)
	w := makeRequest(handler, "POST", "/api/bookings/"+bookingB+"/edit-request",
		map[string]interface{}{
			"new_start_time": overlapTime.Format(time.RFC3339),
		}, token, ctx)
	if w.Code != http.StatusCreated {
		t.Fatalf("failed to create edit request: %d. body: %s", w.Code, w.Body.String())
	}

	editRequestID := getEditRequestIDFromDB(t, ctx, tx, bookingB)

	approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler)
	w = serveAdminHandler(approveHandler, "POST",
		"/api/admin/bookings/"+bookingB+"/edit-requests/"+editRequestID+"/approve",
		"/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx)

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for approve overlapping request, got %d. body: %s", w.Code, w.Body.String())
	}
}

// ============================================================================
// AdminCreateBookingForUserHandler — out_of_hours flag tests
// ============================================================================

// TestAdminCreateBooking_OutOfHours_ExceptionalClosed verifies that when
// out_of_hours=true is set, the admin can create a booking during a period
// that would normally be closed due to exceptional hours (holiday closure).
func TestAdminCreateBooking_OutOfHours_ExceptionalClosed(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	baseTime := weekdayTime(time.Wednesday, 10)

	// Compute the Monday of the week containing baseTime
	weekStart := baseTime.AddDate(0, 0, -int(baseTime.Weekday())+1)
	weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
	weekStartStr := weekStart.Format("2006-01-02")

	// Compute weekday in DB format (0=Monday..6=Sunday)
	dbWeekday := int(baseTime.Weekday())
	if dbWeekday == 0 {
		dbWeekday = 6
	} else {
		dbWeekday -= 1
	}

	// Create an exceptional hours group that makes this weekday CLOSED
	var groupID int
	err = tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test Closure', 'Exceptional closure for out-of-hours test')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional hours group: %v", err)
	}

	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, $2, '00:00', '23:59', false)
	`, groupID, dbWeekday)
	if err != nil {
		t.Fatalf("failed to seed exceptional hours: %v", err)
	}

	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, $2::date)
	`, groupID, weekStartStr)
	if err != nil {
		t.Fatalf("failed to seed exceptional group application: %v", err)
	}

	// Try creating a booking with out_of_hours=true during the closed exceptional period
	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  baseTime,
		ServiceIDs: []string{serviceID},
		OutOfHours: true,
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		t.Errorf("expected 200/201 for out-of-hours booking during exceptional closure, got %d. body: %s",
			w.Code, w.Body.String())
	}

	// Verify the response body includes out_of_hours=true
	var createResp struct {
		Booking Booking `json:"booking"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil {
		t.Fatalf("failed to parse create response: %v", err)
	}
	if !createResp.Booking.OutOfHours {
		t.Error("expected out_of_hours=true in create booking response")
	}
}

// TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails verifies that when
// out_of_hours=false (default), creating a booking during a closed exceptional
// hours period is rejected with a conflict error.
func TestAdminCreateBooking_OutOfHours_WithoutFlag_Fails(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	baseTime := weekdayTime(time.Wednesday, 10)

	// Compute the Monday of the week containing baseTime using London timezone
	// for the weekday, but UTC midnight for the DATE — matching the handler's pattern.
	londonBase := baseTime.In(londonLocation)
	weekStart := londonBase.AddDate(0, 0, -int(londonBase.Weekday())+1)
	weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
	weekStartStr := weekStart.Format("2006-01-02")

	// Compute weekday in DB format (0=Monday..6=Sunday)
	dbWeekday := int(londonBase.Weekday())
	if dbWeekday == 0 {
		dbWeekday = 6
	} else {
		dbWeekday -= 1
	}

	// Create an exceptional hours group that makes this weekday CLOSED
	var groupID int
	err = tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test Closure', 'Exceptional closure for without-flag test')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional hours group: %v", err)
	}

	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, $2, '00:00', '23:59', false)
	`, groupID, dbWeekday)
	if err != nil {
		t.Fatalf("failed to seed exceptional hours: %v", err)
	}

	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, $2::date)
	`, groupID, weekStartStr)
	if err != nil {
		t.Fatalf("failed to seed exceptional group application: %v", err)
	}

	// Try creating a booking WITHOUT out_of_hours during the closed exceptional period
	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  baseTime,
		ServiceIDs: []string{serviceID},
		// OutOfHours intentionally false (zero value)
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for booking during exceptional closure without out_of_hours flag, got %d. body: %s",
			w.Code, w.Body.String())
	}
}

// TestAdminCreateBooking_OutOfHours_OverlapStillChecked verifies that even
// when out_of_hours=true, overlap detection with existing bookings still works.
func TestAdminCreateBooking_OutOfHours_OverlapStillChecked(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create an existing booking at baseTime (confirmed)
	existingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create existing booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", existingID)
	if err != nil {
		t.Fatalf("failed to confirm existing booking: %v", err)
	}

	// Try creating an out-of-hours booking that overlaps (starts dur/2 min after)
	overlapTime := baseTime.Add(time.Duration(dur/2) * time.Minute)

	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  overlapTime,
		ServiceIDs: []string{serviceID},
		OutOfHours: true,
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusConflict {
		t.Errorf("expected 409 for overlapping out-of-hours booking, got %d. body: %s",
			w.Code, w.Body.String())
	}

	// Verify the existing booking was NOT affected
	var status string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", existingID).Scan(&status)
	if err != nil {
		t.Fatalf("failed to query existing booking: %v", err)
	}
	if status != "confirmed" {
		t.Errorf("expected existing booking to remain 'confirmed', got %q", status)
	}
}

// TestAdminCreateBooking_OutOfHours_TimeBlockerWarning verifies that when
// out_of_hours=true is set and a time blocker overlaps, the booking is still
// created with a warning (rather than being rejected), matching the behavior
// of the non-out-of-hours create flow.
func TestAdminCreateBooking_OutOfHours_TimeBlockerWarning(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create a time blocker at the same time as the booking
	_, err = tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'Admin Blocked', NULL)
	`, baseTime)
	if err != nil {
		t.Fatalf("failed to create time blocker: %v", err)
	}

	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  baseTime,
		ServiceIDs: []string{serviceID},
		OutOfHours: true,
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	// AdminCreateBookingForUserHandler issues warnings but does not reject
	// for time blocker overlaps — the booking is created with a warning.
	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		t.Errorf("expected 200/201 (booking created with warning), got %d. body: %s",
			w.Code, w.Body.String())
	}

	// Verify the response includes the time blocker warning
	var createResp struct {
		Warnings []string `json:"warnings"`
	}
	if err := json.Unmarshal(w.Body.Bytes(), &createResp); err != nil {
		t.Fatalf("failed to parse response: %v", err)
	}
	if len(createResp.Warnings) == 0 {
		t.Error("expected warning about time blocker overlap in response")
	}
	foundBlockerWarning := false
	for _, warn := range createResp.Warnings {
		if strings.Contains(warn, "time blocker") {
			foundBlockerWarning = true
			break
		}
	}
	if !foundBlockerWarning {
		t.Errorf("expected warning to mention time blocker, got warnings: %v", createResp.Warnings)
	}
}

// ============================================================================
// Bookings status evolution — overlap edge cases
// ============================================================================

// TestAdminCreateBooking_OverlapWithCancelled_Allowed verifies that creating a
// booking overlapping a cancelled booking is allowed (cancelled bookings are
// excluded from overlap checks).
func TestAdminCreateBooking_OverlapWithCancelled_Allowed(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	baseTime := weekdayTime(time.Wednesday, 10)

	cancelledID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", cancelledID)
	if err != nil {
		t.Fatalf("failed to set client_cancelled: %v", err)
	}

	// Creating a booking overlapping a cancelled one should be OK
	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  baseTime,
		ServiceIDs: []string{serviceID},
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	if w.Code != http.StatusCreated && w.Code != http.StatusOK {
		t.Errorf("expected 200/201 for overlapping completed booking, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminCreateBooking_Weekday_BST_Boundary verifies that AdminCreateBookingForUserHandler
// uses London timezone for the exceptional-hours weekday lookup (Issues 2+3 fix).
// At 23:30 UTC on a Sunday in BST (= 00:30 BST Monday), the handler should look up
// Monday's exceptional hours, not Sunday's. Sunday's row is deleted here, so without
// the fix the lookup would fail the handler. With the fix (London time weekday=Monday),
// the row exists and the handler succeeds.
func TestAdminCreateBooking_Weekday_BST_Boundary(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}

	// Delete Sunday's working hours so a UTC-weekday lookup (Sunday, DB weekday 6) fails.
	_, err = tx.Exec(ctx, "DELETE FROM working_hours WHERE weekday = 6")
	if err != nil {
		t.Fatalf("failed to delete Sunday hours: %v", err)
	}

	// Book at 23:30 UTC on Sunday (= 00:30 BST Monday). Without London weekday,
	// DB weekday = Sunday (6, row deleted). With London weekday = Monday (0, closed via EH).
	sunday2330UTC := time.Date(2099, 6, 14, 23, 30, 0, 0, time.UTC)

	// Compute weekStart the same way the handler does: from the booking time's
	// London weekday, find Monday's date, store as UTC midnight.
	bkLondon := sunday2330UTC.In(londonLocation) // 00:30 BST Monday
	daysToMonday := int(bkLondon.Weekday())      // Monday in Go = 1
	if daysToMonday == 0 {
		daysToMonday = 7
	}
	tm := bkLondon.AddDate(0, 0, -daysToMonday+1)
	weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)
	weekStartStr := weekStart.Format("2006-01-02")
	var groupID int
	err = tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test', '') RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create group: %v", err)
	}
	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, 0, '00:00', '23:59', false)
	`, groupID) // Monday (DB weekday 0) closed
	if err != nil {
		t.Fatalf("failed to seed exceptional hours: %v", err)
	}
	_, err = tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, $2::date)
	`, groupID, weekStartStr)
	if err != nil {
		t.Fatalf("failed to seed application: %v", err)
	}

	body := AdminCreateBookingForUserRequest{
		UserID:     userID,
		StartTime:  sunday2330UTC,
		ServiceIDs: []string{serviceID},
	}

	w := serveChiHandler(AdminCreateBookingForUserHandler, "POST", "/", "/", body,
		func(baseCtx context.Context) context.Context {
			baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
			baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
			return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
		})

	// The handler must reject the booking (BST boundary). It may use 400 or 409
	// depending on whether it hits the working_hours lookup or the EH check first.
	// The important thing is it does NOT return 200/500.
	if w.Code == http.StatusOK || w.Code == http.StatusCreated {
		t.Errorf("expected 4xx rejection at BST boundary, got %d. body: %s", w.Code, w.Body.String())
	}
	if w.Code == http.StatusInternalServerError {
		t.Errorf("unexpected 500 — likely a DB lookup failed due to wrong weekday at BST boundary")
	}
}

// ============================================================================
// pending_release eviction tests — every handler that calls
// EvictPendingReleaseOverlapping must be tested for correct eviction.
// ============================================================================

// TestUpdateBookingServices_ExtendEvictsPendingRelease verifies that extending
// a booking's duration into a pending_release slot evicts it (→ deposit_lapsed)
// rather than rejecting the extension.
func TestUpdateBookingServices_ExtendEvictsPendingRelease(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Booking A — starts at 10:00, confirmed
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Booking B — starts adjacent to A, set to pending_release
	adjacentTime := baseTime.Add(time.Duration(dur) * time.Minute)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, adjacentTime)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to set booking B to pending_release: %v", err)
	}

	// Add a 30-min service to A — extends A into B's slot
	shortSvcID, err := fixtures.CreateTestServiceWithDuration(tx, 30)
	if err != nil {
		t.Fatalf("failed to create short service: %v", err)
	}

	w := serveChiHandler(UpdateBookingServicesHandler, "PUT", "/"+bookingA, "/{id}", map[string]interface{}{
		"service_ids": []string{serviceID, shortSvcID},
	}, func(baseCtx context.Context) context.Context {
		baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
		baseCtx = context.WithValue(baseCtx, mw.UserIDKey, adminID)
		return db.ContextWithTx(baseCtx, db.TxFromContext(ctx))
	})

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify B was evicted to deposit_lapsed
	var newStatus string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus)
	if err != nil {
		t.Fatalf("failed to query booking B: %v", err)
	}
	if newStatus != "deposit_lapsed" {
		t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus)
	}
}

// TestEditBooking_EvictsPendingReleaseOnOverlap verifies that editing a booking's
// start time into a pending_release slot evicts it rather than blocking the edit.
func TestEditBooking_EvictsPendingReleaseOnOverlap(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create the user's own confirmed booking
	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking A: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking A: %v", err)
	}

	// Create a pending_release booking 1h later that A will be edited into
	pendingStart := baseTime.Add(1 * time.Hour)
	bookingB, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingStart)
	if err != nil {
		t.Fatalf("failed to create booking B: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", bookingB)
	if err != nil {
		t.Fatalf("failed to set booking B to pending_release: %v", err)
	}

	// Edit booking A's time to overlap B's slot
	w := makeRequest(http.HandlerFunc(EditBookingHandler), "PUT",
		"/api/bookings/"+bookingA,
		map[string]interface{}{
			"start_time": pendingStart.Add(-15 * time.Minute).Format(time.RFC3339),
		}, token, ctx)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify B was evicted to deposit_lapsed
	var newStatus string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingB).Scan(&newStatus)
	if err != nil {
		t.Fatalf("failed to query booking B: %v", err)
	}
	if newStatus != "deposit_lapsed" {
		t.Errorf("expected booking B to be evicted to 'deposit_lapsed', got %q", newStatus)
	}
}

// TestRequestEdit_AutoApprove_EvictsPendingRelease verifies that the
// RequestEditHandler auto-approve path evicts overlapping pending_release
// bookings when it changes the booking's start time.
func TestRequestEdit_AutoApprove_EvictsPendingRelease(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

	// Booking at a far-future time (>48h from now) so auto-approve triggers
	farTime := clock.Now().Add(120 * time.Hour).Truncate(time.Second)
	farTime = time.Date(farTime.Year(), farTime.Month(), farTime.Day(), 10, 0, 0, 0, farTime.Location())

	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, farTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking: %v", err)
	}

	// Create a pending_release booking at a slightly later time
	pendingTime := farTime.Add(1 * time.Hour)
	pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, pendingTime)
	if err != nil {
		t.Fatalf("failed to create pending_release booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking)
	if err != nil {
		t.Fatalf("failed to set pending_release: %v", err)
	}

	// Request edit to move A into B's slot — auto-approve should evict B
	handler := http.HandlerFunc(RequestEditHandler)
	w := makeRequest(handler, "POST", "/api/bookings/"+bookingA+"/edit-request",
		map[string]interface{}{
			"new_start_time": pendingTime.Format(time.RFC3339),
		}, token, ctx)

	if w.Code != http.StatusOK && w.Code != http.StatusCreated {
		t.Fatalf("expected 200/201 for auto-approved edit (pending_release evicted), got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify pending_release was evicted to deposit_lapsed
	var newStatus string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus)
	if err != nil {
		t.Fatalf("failed to query pending booking: %v", err)
	}
	if newStatus != "deposit_lapsed" {
		t.Errorf("expected pending_release booking to be evicted to 'deposit_lapsed', got %q", newStatus)
	}
}

// TestCreateBooking_ReservationDoesNotSelfBlock verifies that the reservation
// time_blocker created by the reserve step does NOT block CreateBookingHandler.
// The reservation cleanup must happen BEFORE CheckTimeBlockerOverlap.
func TestCreateBooking_ReservationDoesNotSelfBlock(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

	// Use a far-future weekday so closing-time and deposit checks pass
	future := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)

	// Simulate the reserve step: create a RESERVATION time_blocker at this slot
	// using the same description format as ReserveSlotHandler for logged-in users
	desc := fmt.Sprintf("RESERVATION:user:%s:%d", userID, clock.Now().UnixNano())
	var blockerID string
	err = tx.QueryRow(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, $2, $3, $4)
		RETURNING id
	`, future, 60, desc, userID).Scan(&blockerID)
	if err != nil {
		t.Fatalf("failed to create reservation time_blocker: %v", err)
	}

	// Now call CreateBookingHandler — must succeed despite the reservation
	w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
		&CreateBookingRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("reservation time_blocker should NOT self-block CreateBookingHandler: got 409. body: %s", w.Body.String())
	}
	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201 after reservation cleanup, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify the reservation was also cleaned up inside the transaction
	var remaining int
	tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", future).Scan(&remaining)
	if remaining != 0 {
		t.Errorf("expected reservation to be cleaned up, got %d remaining", remaining)
	}
}

// TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous verifies that an
// anonymous RESERVATION:anon: time_blocker (created_by = NULL) does NOT block
// CreateBookingHandler. This simulates an anonymous user who reserves a slot,
// then logs in and creates the booking.
func TestCreateBooking_ReservationDoesNotSelfBlock_Anonymous(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

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

	// Create an ANONYMOUS reservation (created_by = NULL) at this slot.
	// This is what ReserveSlotHandler creates for anonymous users.
	var blockerID string
	err = tx.QueryRow(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, $2, $3, NULL)
		RETURNING id
	`, future, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID)
	if err != nil {
		t.Fatalf("failed to create anon reservation: %v", err)
	}

	// User is now logged in — CreateBookingHandler must clear the anon
	// reservation via the start_time match.
	w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
		&CreateBookingRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("anonymous reservation should NOT self-block after login: got 409. body: %s", w.Body.String())
	}
	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201 for anon reservation cleanup, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify the anon reservation was cleaned up
	var remaining int
	tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
	if remaining != 0 {
		t.Errorf("expected anonymous reservation to be cleaned up, got %d remaining", remaining)
	}
}

// TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch
// verifies that an anonymous RESERVATION for a DIFFERENT time slot is NOT
// deleted — only reservations at the exact start_time being booked.
func TestCreateBooking_ReservationDoesNotSelfBlock_AnonRemainsIfNoStartMatch(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

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

	// Create an anonymous reservation at a DIFFERENT time
	differentTime := future.Add(2 * time.Hour)
	var blockerID string
	err = tx.QueryRow(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, $2, $3, NULL)
		RETURNING id
	`, differentTime, 60, fmt.Sprintf("RESERVATION:anon:testhash:%d", clock.Now().UnixNano())).Scan(&blockerID)
	if err != nil {
		t.Fatalf("failed to create anon reservation: %v", err)
	}

	// Book a DIFFERENT slot — the anon reservation should remain untouched
	w := makeRequest(http.HandlerFunc(CreateBookingHandler), "POST", "/api/bookings",
		&CreateBookingRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, ctx)

	if w.Code != http.StatusCreated {
		t.Fatalf("expected 201 for non-conflicting slot, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify the anon reservation at the other time was NOT deleted
	var remaining int
	tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
	if remaining != 1 {
		t.Errorf("expected anon reservation at different time to remain, got %d", remaining)
	}
}

// TestReserveSlot_DoesNotSelfBlock verifies that calling ReserveSlotHandler
// for the same slot twice (logged-in) does not fail.
func TestReserveSlot_DoesNotSelfBlock(t *testing.T) {
	t.Parallel()
	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 service: %v", err)
	}

	future := weekdayTime(time.Monday, 10)

	w := makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve",
		&ReserveSlotRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, ctx)

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

	w = makeRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve",
		&ReserveSlotRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("second reserve: self-blocked (got 409) — ReserveSlotHandler should not self-block. body: %s", w.Body.String())
	}
	if w.Code != http.StatusCreated {
		t.Fatalf("second reserve: expected 201, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminReserveSlot_DoesNotSelfBlock verifies that calling AdminReserveSlotHandler
// for the same slot twice does not fail.
func TestAdminReserveSlot_DoesNotSelfBlock(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	// Create an admin user in the DB so the foreign key constraint is satisfied
	adminID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET account_role = 'admin' WHERE id = $1", adminID)
	if err != nil {
		t.Fatalf("failed to set admin role: %v", err)
	}
	adminToken := jwt.GenerateTestToken(adminID, "admin")

	future := weekdayTime(time.Monday, 11)

	reqBody := AdminReserveSlotRequest{
		StartTime:       future,
		DurationMinutes: 60,
		ReservationType: "walkin",
		TTLMinutes:      15,
	}

	w := makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve",
		reqBody, adminToken, ctx)

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

	w = makeRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve",
		reqBody, adminToken, ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("second admin reserve: self-blocked (got 409) — AdminReserveSlotHandler should not self-block. body: %s", w.Body.String())
	}
	if w.Code != http.StatusCreated {
		t.Fatalf("second admin reserve: expected 201, got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminApproveEditRequest_EvictsPendingRelease verifies that approving an
// edit request evicts overlapping pending_release bookings at the new time slot.
func TestAdminApproveEditRequest_EvictsPendingRelease(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}
	token := jwt.GenerateUserToken(userID)

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

	// Use a booking <48h from now so RequestEdit does NOT auto-approve
	nearTime := clock.Now().Add(40 * time.Hour).Truncate(time.Second)
	nearTime = time.Date(nearTime.Year(), nearTime.Month(), nearTime.Day(), 10, 0, 0, 0, nearTime.Location())

	bookingA, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, nearTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingA)
	if err != nil {
		t.Fatalf("failed to confirm booking: %v", err)
	}

	// Create an edit request to move A to a new time
	newTime := nearTime.Add(2 * time.Hour)

	reqHandler := http.HandlerFunc(RequestEditHandler)
	w := makeRequest(reqHandler, "POST", "/api/bookings/"+bookingA+"/edit-request",
		map[string]interface{}{
			"new_start_time": newTime.Format(time.RFC3339),
		}, token, ctx)
	if w.Code != http.StatusCreated {
		t.Fatalf("failed to create edit request: %d. body: %s", w.Code, w.Body.String())
	}

	// Get the edit request ID
	var editRequestID string
	err = tx.QueryRow(ctx, "SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingA).Scan(&editRequestID)
	if err != nil {
		t.Fatalf("failed to get edit request ID: %v", err)
	}

	// Create a pending_release booking at the NEW target time (overlapping the edit request)
	pendingBooking, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, newTime)
	if err != nil {
		t.Fatalf("failed to create pending_release booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'pending_release' WHERE id = $1", pendingBooking)
	if err != nil {
		t.Fatalf("failed to set pending_release: %v", err)
	}
	_ = dur

	// Approve the edit request as admin — should evict the pending_release
	approveHandler := http.HandlerFunc(AdminApproveEditRequestHandler)
	w = serveAdminHandler(approveHandler, "POST",
		"/api/admin/bookings/"+bookingA+"/edit-requests/"+editRequestID+"/approve",
		"/api/admin/bookings/{id}/edit-requests/{request_id}/approve", nil, ctx)

	if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
		t.Fatalf("expected 200/204 for approve after evicting pending_release, got %d. body: %s", w.Code, w.Body.String())
	}

	// Verify pending_release was evicted to deposit_lapsed
	var newStatus string
	err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", pendingBooking).Scan(&newStatus)
	if err != nil {
		t.Fatalf("failed to query pending booking: %v", err)
	}
	if newStatus != "deposit_lapsed" {
		t.Errorf("expected pending_release to be evicted to 'deposit_lapsed', got %q", newStatus)
	}
}

// TestReserveSlot_CleansUpAnonReservation verifies that ReserveSlotHandler
// cleans up anonymous RESERVATION:anon entries matching the user's IP
// when the user is authenticated (IP hash matching in pre-overlap DELETE).
func TestReserveSlot_CleansUpAnonReservation(t *testing.T) {
	t.Parallel()
	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 service: %v", err)
	}

	future := weekdayTime(time.Monday, 10)

	// Set a known IP that the test request will use
	testIP := "192.0.2.1"
	ipHash := fmt.Sprintf("%x", md5.Sum([]byte(testIP)))[:8]

	// Create an anonymous reservation (created_by = NULL) matching this IP hash
	var blockerID string
	err = tx.QueryRow(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, $2, $3, NULL)
		RETURNING id
	`, future, 60, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID)
	if err != nil {
		t.Fatalf("failed to create anon reservation: %v", err)
	}

	// Call ReserveSlotHandler with CF-Connecting-IP header set to match the anon reservation
	makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token string, ip string, requestCtx ...context.Context) *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)
		}
		if token != "" {
			req.Header.Set("Authorization", "Bearer "+token)
		}
		req.Header.Set("CF-Connecting-IP", ip)

		baseCtx := req.Context()
		if len(requestCtx) > 0 {
			baseCtx = requestCtx[0]
		}
		rctx := chi.NewRouteContext()
		ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx)
		req = req.WithContext(ctx)
		w := httptest.NewRecorder()
		handler.ServeHTTP(w, req)
		return w
	}

	// First call should succeed — creates RESERVATION:user entry
	w := makeIPRequest(http.HandlerFunc(ReserveSlotHandler), "POST", "/api/bookings/reserve",
		&ReserveSlotRequest{
			StartTime:  future,
			ServiceIDs: []string{serviceID},
		}, token, testIP, ctx)

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

	// Verify the anon reservation was cleaned up by the pre-overlap DELETE
	var remaining int
	tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
	if remaining != 0 {
		t.Errorf("expected anonymous reservation to be cleaned up by pre-overlap DELETE, got %d remaining", remaining)
	}
}

// TestEditBooking_DoesNotSelfBlock_OwnReservation verifies that a user's own
// RESERVATION does not block EditBookingHandler — the excludeUserID parameter
// prevents the reservation from appearing as a time blocker conflict.
func TestEditBooking_DoesNotSelfBlock_OwnReservation(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
	if err != nil {
		t.Fatalf("failed to set deposits_required: %v", err)
	}

	serviceID, err := fixtures.CreateTestService(tx)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	dur := durationMinutes(t, ctx, tx, serviceID)
	token := jwt.GenerateUserToken(userID)

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create a confirmed booking
	bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
	if err != nil {
		t.Fatalf("failed to confirm booking: %v", err)
	}

	// Create a RESERVATION for this user at a time that overlaps with the edit target
	reservationTime := baseTime.Add(time.Duration(dur) * time.Minute)
	_, err = tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
	`, reservationTime, userID)
	if err != nil {
		t.Fatalf("failed to create reservation: %v", err)
	}

	// Edit the booking to a time that overlaps the reservation.
	// Without excludeUserID, this would return 409.
	overlapTime := reservationTime.Add(-time.Duration(dur/2) * time.Minute)

	handler := http.HandlerFunc(EditBookingHandler)
	w := makeRequest(handler, "PUT", "/api/bookings/"+bookingID, map[string]interface{}{
		"start_time":  overlapTime.Format(time.RFC3339),
		"service_ids": []string{serviceID},
	}, token, ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("own reservation should NOT self-block EditBookingHandler: got 409. body: %s", w.Body.String())
	}
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for edit (own reservation excluded), got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminRescheduleBooking_DoesNotSelfBlock verifies that an admin's own
// RESERVATION does not block AdminRescheduleBookingHandler.
func TestAdminRescheduleBooking_DoesNotSelfBlock(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	token := jwt.GenerateTestToken(adminID, "admin")

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

	baseTime := weekdayTime(time.Wednesday, 10)

	// Create a booking belonging to a regular user
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, baseTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
	if err != nil {
		t.Fatalf("failed to confirm booking: %v", err)
	}

	// Create an admin RESERVATION at a time that would overlap the reschedule target
	reservationTime := baseTime.Add(2 * time.Hour)
	_, err = tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:' || EXTRACT(epoch FROM NOW())::bigint::text, $2)
	`, reservationTime, adminID)
	if err != nil {
		t.Fatalf("failed to create admin reservation: %v", err)
	}

	// Reschedule to a time overlapping the admin's own reservation
	overlapTime := reservationTime.Add(-30 * time.Minute)

	handler := http.HandlerFunc(AdminRescheduleBookingHandler)
	w := makeAuthRequest(handler, "POST", "/api/admin/bookings/"+bookingID+"/reschedule", map[string]interface{}{
		"start_time": overlapTime.Format(time.RFC3339),
	}, token, "", ctx)

	if w.Code == http.StatusConflict {
		t.Fatalf("admin's own reservation should NOT self-block AdminRescheduleBookingHandler: got 409. body: %s", w.Body.String())
	}
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200 for reschedule (own reservation excluded), got %d. body: %s", w.Code, w.Body.String())
	}
}

// TestAdminReserveSlot_CleansUpAnonReservation verifies that AdminReserveSlotHandler
// cleans up anonymous RESERVATION:anon entries matching the admin's IP
// when the admin is authenticated.
func TestAdminReserveSlot_CleansUpAnonReservation(t *testing.T) {
	t.Parallel()
	ctx, tx := testutils.SetupTestTx(t)

	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin: %v", err)
	}
	token := jwt.GenerateTestToken(adminID, "admin")

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

	future := weekdayTime(time.Monday, 10)
	testIP := "192.0.2.2"
	ipHash := fmt.Sprintf("%x", md5.Sum([]byte(testIP)))[:8]

	// Create an anonymous reservation matching this IP
	var blockerID string
	err = tx.QueryRow(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, $2, NULL)
		RETURNING id
	`, future, fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, clock.Now().UnixNano())).Scan(&blockerID)
	if err != nil {
		t.Fatalf("failed to create anon reservation: %v", err)
	}

	makeIPRequest := func(handler http.Handler, method, path string, body interface{}, token, ip, userID string, requestCtx ...context.Context) *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)
		}
		if token != "" {
			req.Header.Set("Authorization", "Bearer "+token)
		}
		req.Header.Set("CF-Connecting-IP", ip)
		baseCtx := req.Context()
		if len(requestCtx) > 0 {
			baseCtx = requestCtx[0]
		}
		baseCtx = context.WithValue(baseCtx, mw.UserIDKey, userID)
		baseCtx = context.WithValue(baseCtx, mw.UserRoleKey, "admin")
		rctx := chi.NewRouteContext()
		ctx := context.WithValue(baseCtx, chi.RouteCtxKey, rctx)
		req = req.WithContext(ctx)
		w := httptest.NewRecorder()
		handler.ServeHTTP(w, req)
		return w
	}

	w := makeIPRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve",
		&AdminReserveSlotRequest{
			StartTime:       future,
			ServiceIDs:      []string{serviceID},
			DurationMinutes: 60,
			ReservationType: "walkin",
		}, token, testIP, adminID, ctx)

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

	var remaining int
	tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE id = $1", blockerID).Scan(&remaining)
	if remaining != 0 {
		t.Errorf("expected anonymous reservation to be cleaned up by admin pre-overlap DELETE, got %d remaining", remaining)
	}
}
