//go:build test

package scheduling

// Package scheduling contains tests for working hours and availability endpoints.
//
// Test Coverage:
//   - GetDefaultHours: GET /api/scheduling/default-hours - Get default weekly hours
//   - UpdateDefaultHours: PUT /api/scheduling/default-hours - Update default hours (admin)
//   - ListExceptionalGroups: GET /api/scheduling/exceptional-groups - List holiday hour groups
//   - CreateExceptionalGroup: POST /api/scheduling/exceptional-groups - Create group (admin)
//   - DeleteExceptionalGroup: DELETE /api/scheduling/exceptional-groups?id=X - Delete (admin)
//   - GetWorkingHours: GET /api/scheduling/working-hours?start=X&end=Y - Get hours for date range
//   - GetAvailableHours: GET /api/scheduling/available-hours?start=X&end=Y - Get available slots
//   - UpdateExceptionalApplications: PUT /api/scheduling/exceptional-applications - Apply holidays
//
// Authentication: Update/Create/Delete endpoints require admin role (403 for non-admins).
import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"testing"
	"time"

	"crussell/clock"
	"crussell/db"
	"crussell/mw"
	"crussell/testutils"
	"crussell/testutils/fixtures"
	"crussell/testutils/jwt"
)

func resetTestData(t *testing.T) (context.Context, db.Querier) {
	t.Helper()
	ctx, tx := testutils.SetupTestTx(t)
	return ctx, tx
}

func makeRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx 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)
	}
	req = req.WithContext(ctx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	return w
}

func makeAuthRequest(handler http.Handler, method, path, token string, body interface{}, ctx 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 = req.WithContext(ctx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	return w
}

// --- Tests for GetDefaultHours ---

// TestScheduling_GetDefaultHours verifies that the default weekly working hours
// can be retrieved. The test checks that all 7 days are returned with correct
// opening times, closing times, and is_open status.
func TestScheduling_GetDefaultHours(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	handler := http.HandlerFunc(GetDefaultHours)
	w := makeRequest(handler, "GET", "/api/scheduling/default-hours", nil, ctx)

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

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

	if len(response) != 7 {
		t.Errorf("expected 7 days of hours, got %d", len(response))
	}

	// Verify Monday (weekday 0) has our seeded hours
	var monday *DefaultHours
	for i := range response {
		if response[i].Weekday == 0 {
			monday = &response[i]
			break
		}
	}
	if monday == nil {
		t.Fatal("expected Monday hours in response")
	}
	// Database returns HH:MM:SS format
	if monday.StartTime != "09:00:00" {
		t.Errorf("expected Monday start time 09:00:00, got %s", monday.StartTime)
	}
	if monday.EndTime != "17:00:00" {
		t.Errorf("expected Monday end time 17:00:00, got %s", monday.EndTime)
	}
	if !monday.IsOpen {
		t.Error("expected Monday to be open")
	}
}

// --- Tests for UpdateDefaultHours ---

// TestScheduling_UpdateDefaultHours_Admin tests that an admin can update
// the default weekly working hours. The new schedule is persisted to the
// database and returned on subsequent requests.
func TestScheduling_UpdateDefaultHours_Admin(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	adminToken := jwt.GenerateAdminToken()
	handler := http.HandlerFunc(UpdateDefaultHours)

	newHours := []DefaultHours{
		{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
		{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
	}

	w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, newHours, ctx)

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

	// Verify the update persisted
	var hours []DefaultHours
	rows, err := tx.Query(ctx, `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours ORDER BY weekday`)
	if err != nil {
		t.Fatalf("failed to query hours: %v", err)
	}
	defer rows.Close()

	for rows.Next() {
		var h DefaultHours
		if err := rows.Scan(&h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
			t.Fatalf("failed to scan hours: %v", err)
		}
		hours = append(hours, h)
	}

	if hours[0].StartTime != "08:00:00" {
		t.Errorf("expected Monday start time 08:00:00, got %s", hours[0].StartTime)
	}
}

// TestScheduling_UpdateDefaultHours_NonAdmin verifies that non-admin users
// receive HTTP 403 Forbidden when attempting to update default hours.
func TestScheduling_UpdateDefaultHours_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	userToken := jwt.GenerateUserToken("user-123")

	newHours := []DefaultHours{
		{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
		{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
		{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
	}

	// Wrap handler with RequireAuth + RequireAdmin middleware (auth first to populate context)
	w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateDefaultHours))), "PUT", "/api/scheduling/default-hours", userToken, newHours, ctx)

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

// --- Tests for ListExceptionalGroups ---

// TestScheduling_ListExceptionalGroups verifies that admins can list all
// exceptional working hours groups (holidays, special events).
func TestScheduling_ListExceptionalGroups(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Create an exceptional group
	_, err := tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Holiday Hours', 'Christmas holiday schedule')
	`)
	if err != nil {
		t.Fatalf("failed to create group: %v", err)
	}

	handler := http.HandlerFunc(ListExceptionalGroups)
	w := makeRequest(handler, "GET", "/api/scheduling/exceptional-groups", nil, ctx)

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

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

	if len(response) == 0 {
		t.Error("expected at least one group in response")
	}

	if response[0].Name != "Holiday Hours" {
		t.Errorf("expected group name 'Holiday Hours', got %s", response[0].Name)
	}
}

// --- Tests for CreateExceptionalGroup ---

// TestScheduling_CreateExceptionalGroup_Admin tests that an admin can
// create a new exceptional working hours group with specific hours for each day.
func TestScheduling_CreateExceptionalGroup_Admin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	adminToken := jwt.GenerateAdminToken()
	handler := http.HandlerFunc(CreateExceptionalGroup)

	newGroup := ExceptionalGroup{
		Name:        "Summer Hours",
		Description: "Extended summer schedule",
		Hours: []ExceptionalHours{
			{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
			{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
		},
		WeekStarts: []string{"2026-06-01"},
	}

	w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, newGroup, ctx)

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

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

	if response.Name != "Summer Hours" {
		t.Errorf("expected group name 'Summer Hours', got %s", response.Name)
	}
	if len(response.Hours) != 7 {
		t.Errorf("expected 7 hours, got %d", len(response.Hours))
	}
}

// TestScheduling_CreateExceptionalGroup_NonAdmin verifies that non-admin
// users receive HTTP 403 when attempting to create exceptional groups.
func TestScheduling_CreateExceptionalGroup_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	userToken := jwt.GenerateUserToken("user-123")

	newGroup := ExceptionalGroup{
		Name:        "Summer Hours",
		Description: "Extended summer schedule",
		Hours: []ExceptionalHours{
			{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
			{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
		},
		WeekStarts: []string{"2026-06-01"},
	}

	w := makeAuthRequest(mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(CreateExceptionalGroup))), "POST", "/api/scheduling/exceptional-groups", userToken, newGroup, ctx)

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

// --- Tests for 15-minute interval validation ---

// TestScheduling_UpdateDefaultHours_InvalidTimes verifies that UpdateDefaultHours
// rejects start_time/end_time with minutes not in {00, 15, 30, 45}.
func TestScheduling_UpdateDefaultHours_InvalidTimes(t *testing.T) {
	handler := http.HandlerFunc(UpdateDefaultHours)
	adminToken := jwt.GenerateAdminToken()

	tests := []struct {
		name       string
		hours      []DefaultHours
		wantStatus int
	}{
		{"valid 00 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:00", IsOpen: true}}, http.StatusNoContent},
		{"valid 15 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:15", EndTime: "17:15", IsOpen: true}}, http.StatusNoContent},
		{"valid 30 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:30", EndTime: "17:30", IsOpen: true}}, http.StatusNoContent},
		{"valid 45 minutes", []DefaultHours{{Weekday: 0, StartTime: "09:45", EndTime: "17:45", IsOpen: true}}, http.StatusNoContent},
		{"valid HH:MM:SS 00", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusNoContent},
		{"valid HH:MM:SS 15", []DefaultHours{{Weekday: 0, StartTime: "09:15:00", EndTime: "17:15:00", IsOpen: true}}, http.StatusNoContent},
		{"valid HH:MM:SS 30", []DefaultHours{{Weekday: 0, StartTime: "09:30:00", EndTime: "17:30:00", IsOpen: true}}, http.StatusNoContent},
		{"valid HH:MM:SS 45", []DefaultHours{{Weekday: 0, StartTime: "09:45:00", EndTime: "17:45:00", IsOpen: true}}, http.StatusNoContent},
		{"invalid start_time :07", []DefaultHours{{Weekday: 0, StartTime: "09:07", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest},
		{"invalid start_time :22", []DefaultHours{{Weekday: 0, StartTime: "09:22", EndTime: "17:00", IsOpen: true}}, http.StatusBadRequest},
		{"invalid end_time :59", []DefaultHours{{Weekday: 0, StartTime: "09:00", EndTime: "17:59", IsOpen: true}}, http.StatusBadRequest},
		{"invalid HH:MM:SS :07", []DefaultHours{{Weekday: 0, StartTime: "09:07:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest},
		{"invalid HH:MM:SS :22", []DefaultHours{{Weekday: 0, StartTime: "09:22:00", EndTime: "17:00:00", IsOpen: true}}, http.StatusBadRequest},
		{"invalid HH:MM:SS :59", []DefaultHours{{Weekday: 0, StartTime: "09:00:00", EndTime: "17:59:00", IsOpen: true}}, http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctx, _ := resetTestData(t)
			w := makeAuthRequest(handler, "PUT", "/api/scheduling/default-hours", adminToken, tt.hours, ctx)
			if w.Code != tt.wantStatus {
				t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String())
			}
		})
	}
}

// TestScheduling_CreateExceptionalGroup_InvalidTimes verifies that CreateExceptionalGroup
// rejects start_time/end_time with minutes not in {00, 15, 30, 45}.
func TestScheduling_CreateExceptionalGroup_InvalidTimes(t *testing.T) {
	handler := http.HandlerFunc(CreateExceptionalGroup)
	adminToken := jwt.GenerateAdminToken()

	baseHours := func() []ExceptionalHours {
		return []ExceptionalHours{
			{Weekday: 0, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 1, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 2, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 3, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 4, StartTime: "08:00", EndTime: "18:00", IsOpen: true},
			{Weekday: 5, StartTime: "09:00", EndTime: "17:00", IsOpen: true},
			{Weekday: 6, StartTime: "00:00", EndTime: "00:00", IsOpen: false},
		}
	}

	tests := []struct {
		name       string
		modify     func([]ExceptionalHours) []ExceptionalHours
		wantStatus int
	}{
		{"valid times (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours { return h }, http.StatusCreated},
		{"valid HH:MM:SS (00,15,30,45)", func(h []ExceptionalHours) []ExceptionalHours {
			h[0].StartTime = "08:00:00"
			h[0].EndTime = "18:00:00"
			h[1].StartTime = "09:15:00"
			h[1].EndTime = "17:30:00"
			return h
		}, http.StatusCreated},
		{"invalid start_time :07", func(h []ExceptionalHours) []ExceptionalHours { h[0].StartTime = "09:07"; return h }, http.StatusBadRequest},
		{"invalid end_time :22", func(h []ExceptionalHours) []ExceptionalHours { h[0].EndTime = "17:22"; return h }, http.StatusBadRequest},
		{"invalid start_time :59", func(h []ExceptionalHours) []ExceptionalHours { h[1].StartTime = "10:59"; return h }, http.StatusBadRequest},
		{"invalid HH:MM:SS :07", func(h []ExceptionalHours) []ExceptionalHours {
			h[0].StartTime = "09:07:00"
			return h
		}, http.StatusBadRequest},
		{"invalid HH:MM:SS :59", func(h []ExceptionalHours) []ExceptionalHours {
			h[1].EndTime = "18:59:00"
			return h
		}, http.StatusBadRequest},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctx, _ := resetTestData(t)
			hours := tt.modify(baseHours())
			group := ExceptionalGroup{
				Name:        "Test Group",
				Description: "Test",
				Hours:       hours,
				WeekStarts:  []string{"2026-06-01"},
			}
			w := makeAuthRequest(handler, "POST", "/api/scheduling/exceptional-groups", adminToken, group, ctx)
			if w.Code != tt.wantStatus {
				t.Errorf("expected status %d, got %d. body: %s", tt.wantStatus, w.Code, w.Body.String())
			}
		})
	}
}

// --- Tests for DeleteExceptionalGroup ---

// TestScheduling_DeleteExceptionalGroup_Admin tests that an admin can delete
// an exceptional working hours group. This removes the group and its associated
// hours from the system.
func TestScheduling_DeleteExceptionalGroup_Admin(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	adminToken := jwt.GenerateAdminToken()

	// Create a group to delete
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('To Delete', 'Will be deleted')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create group: %v", err)
	}

	handler := http.HandlerFunc(DeleteExceptionalGroup)
	// Use proper URL query with strconv.Itoa
	req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id="+strconv.Itoa(groupID), nil)
	req.Header.Set("Authorization", "Bearer "+adminToken)
	req = req.WithContext(ctx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

	// Verify group was deleted
	var count int
	err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM exceptional_working_hours_groups WHERE id = $1`, groupID).Scan(&count)
	if err != nil {
		t.Fatalf("failed to check group: %v", err)
	}
	if count != 0 {
		t.Error("expected group to be deleted")
	}
}

// TestScheduling_DeleteExceptionalGroup_NonAdmin verifies that non-admin
// users receive HTTP 403 when attempting to delete exceptional groups.
func TestScheduling_DeleteExceptionalGroup_NonAdmin(t *testing.T) {
	t.Parallel()
	_, _ = resetTestData(t)

	userToken := jwt.GenerateUserToken("user-123")

	handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(DeleteExceptionalGroup)))
	req := httptest.NewRequest("DELETE", "/api/scheduling/exceptional-groups?id=1", nil)
	req.Header.Set("Authorization", "Bearer "+userToken)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

// --- Tests for GetWorkingHours ---

// TestScheduling_GetWorkingHours verifies that working hours can be
// retrieved for a given date range. The response includes whether hours come
// from default schedule or exceptional groups.
func TestScheduling_GetWorkingHours(t *testing.T) {
	t.Parallel()
	_, _ = resetTestData(t)

	handler := http.HandlerFunc(GetWorkingHours)
	req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22", nil)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Error("expected working hours in response")
	}

	// Verify source is "default" for seeded hours
	for _, day := range response {
		if day.Source != "default" {
			t.Errorf("expected source 'default', got %s", day.Source)
		}
		break
	}
}

// --- Tests for GetAvailableHours ---

// TestScheduling_GetAvailableHours tests that available appointment
// slots can be calculated for a date range based on working hours and service
// durations.
func TestScheduling_GetAvailableHours(t *testing.T) {
	t.Parallel()
	_, _ = resetTestData(t)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22", nil)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Error("expected available hours in response")
	}

	// Verify we have slots for open days
	for _, day := range response {
		if day.IsOpen {
			if len(day.Slots) == 0 {
				t.Error("expected slots for open days")
			}
			break
		}
	}
}

// TestScheduling_GetWorkingHours_OutOfHours_Admin verifies that when an admin
// calls GetWorkingHours with out_of_hours=true, ALL days return isOpen=true
// with startTime=06:00 and endTime=22:00 (including normally-closed days).
func TestScheduling_GetWorkingHours_OutOfHours_Admin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	handler := http.HandlerFunc(GetWorkingHours)
	req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	req = req.WithContext(ctx)
	// Set admin role in context (simulates OptionalAuth setting the role)
	req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Fatal("expected working hours in response")
	}

	for _, day := range response {
		if !day.IsOpen {
			t.Errorf("expected all days to be open with out_of_hours, got isOpen=false for %s", day.Date)
		}
		if day.StartTime != "06:00" {
			t.Errorf("expected startTime=06:00 for %s, got %s", day.Date, day.StartTime)
		}
		if day.EndTime != "22:00" {
			t.Errorf("expected endTime=22:00 for %s, got %s", day.Date, day.EndTime)
		}
	}
}

// TestScheduling_GetWorkingHours_OutOfHours_NonAdmin verifies that when a
// non-admin calls GetWorkingHours with out_of_hours=true, the flag is silently
// ignored and normal hours are returned.
func TestScheduling_GetWorkingHours_OutOfHours_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	handler := http.HandlerFunc(GetWorkingHours)
	req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	req = req.WithContext(ctx)
	// Set non-admin role
	req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Fatal("expected working hours in response")
	}

	for _, day := range response {
		if day.StartTime == "06:00" {
			t.Errorf("non-admin should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
			break
		}
	}
}

// TestScheduling_GetWorkingHours_OutOfHours_NoAuth verifies that when no auth
// context is present (unauthenticated user), out_of_hours=true is silently ignored.
func TestScheduling_GetWorkingHours_OutOfHours_NoAuth(t *testing.T) {
	t.Parallel()
	_, _ = resetTestData(t)

	handler := http.HandlerFunc(GetWorkingHours)
	req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	for _, day := range response {
		if day.StartTime == "06:00" {
			t.Errorf("unauthenticated user should not get out_of_hours hours, got startTime=06:00 for %s", day.Date)
			break
		}
	}
}

// TestScheduling_GetAvailableHours_OutOfHours_Admin verifies that when an
// admin calls GetAvailableHours with out_of_hours=true, slots are generated
// for ALL days (including normally-closed ones like weekends).
func TestScheduling_GetAvailableHours_OutOfHours_Admin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	req = req.WithContext(ctx)
	req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Fatal("expected available hours in response")
	}

	for _, day := range response {
		if !day.IsOpen {
			t.Errorf("expected all days isOpen=true with out_of_hours, got isOpen=false for %s", day.Date)
		}
		if len(day.Slots) == 0 {
			t.Errorf("expected slots for all days with out_of_hours, got none for %s", day.Date)
		}
	}
}

// TestScheduling_GetAvailableHours_OutOfHours_NonAdmin verifies that when a
// non-admin calls GetAvailableHours with out_of_hours=true, the flag is ignored
// and normal availability is returned (closed days have no slots).
func TestScheduling_GetAvailableHours_OutOfHours_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	req = req.WithContext(ctx)
	req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "user"))
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	for _, day := range response {
		// Closed days should still be closed for non-admin even with out_of_hours flag
		if !day.IsOpen && len(day.Slots) > 0 {
			t.Errorf("non-admin should not get slots for closed day %s", day.Date)
		}
		// All source should be "default" not "out_of_hours"
		if day.Source == "out_of_hours" {
			t.Errorf("non-admin should not get source=out_of_hours for %s", day.Date)
		}
	}
}

// TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings verifies that
// out-of-hours mode still subtracts existing bookings from available slots.
// Ensuring the available-hours response is the source of truth for slot data.
func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Create a user + booking starting at 09:00 for 60 min on a weekday
	var userID, serviceID string
	err := tx.QueryRow(ctx, `
		INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
		VALUES ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
		RETURNING id
	`).Scan(&userID)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	err = tx.QueryRow(ctx, `
		INSERT INTO services (name, price, duration_minutes)
		VALUES ('Test Service', 10, 60)
		RETURNING id
	`).Scan(&serviceID)
	if err != nil {
		t.Fatalf("failed to create service: %v", err)
	}
	// Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00)
	bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.UTC)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, created_at)
		VALUES ($1, $2, 'confirmed', NOW())
	`, userID, bookingTime)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	// Add booking service so end_time trigger computes correctly
	_, err = tx.Exec(ctx, `
		INSERT INTO booking_services (booking_id, service_id)
		VALUES ((SELECT id FROM bookings WHERE user_id = $1 AND start_time = $2), $3)
	`, userID, bookingTime, serviceID)
	if err != nil {
		t.Fatalf("failed to link booking service: %v", err)
	}

	// Call GetAvailableHours with out_of_hours=true for a range including Tuesday
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-02-16&end=2026-02-22&out_of_hours=true", nil)
	req = req.WithContext(ctx)
	req = req.WithContext(context.WithValue(req.Context(), mw.UserRoleKey, "admin"))
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	// Find Tuesday 2026-02-17 and verify the 09:00-10:00 slot is excluded
	var tuesday DayAvailableHours
	for _, day := range response {
		if day.Date == "2026-02-17" {
			tuesday = day
			break
		}
	}
	if tuesday.Date == "" {
		t.Fatal("expected Tuesday 2026-02-17 in response")
	}
	if !tuesday.IsOpen {
		t.Fatal("expected Tuesday to be open with out_of_hours")
	}

	// Verify no slot starts between 09:00 and 10:00 (the booking blocks it)
	for _, slot := range tuesday.Slots {
		startMin := timeToMinutesForTest(slot.StartTime)
		if startMin >= 540 && startMin < 600 { // 09:00-10:00 in minutes
			t.Errorf("expected booking at 09:00 to block slots, but found slot at %s", slot.StartTime)
		}
	}

	// Verify we still have slots outside the booking window (e.g. 06:00-09:00)
	hasPreBookingSlot := false
	for _, slot := range tuesday.Slots {
		startMin := timeToMinutesForTest(slot.StartTime)
		if startMin < 540 { // before 09:00
			hasPreBookingSlot = true
			break
		}
	}
	if !hasPreBookingSlot {
		t.Error("expected slots before 09:00 (pre-booking) with out_of_hours on Tuesday")
	}
}

// TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen verifies that
// out-of-hours mode correctly reflects exceptional hours data - the available
// slots come from available-hours API (which includes exceptional hours adjustments),
// not just from the default 06:00-22:00 range.
func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	today := clock.Now()
	weekday := int(today.Weekday())
	if weekday == 0 {
		weekday = 6
	} else {
		weekday -= 1
	}

	// Override working hours - today is closed by default
	_, err := tx.Exec(ctx, `
		INSERT INTO working_hours (weekday, start_time, end_time, is_open)
		VALUES ($1, '09:00', '17:00', false)
		ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '17:00', is_open = false
	`, weekday)
	if err != nil {
		t.Fatalf("failed to seed working hours: %v", err)
	}

	// Add exceptional open hours for today (09:00-13:00, open)
	daysSinceMonday := int(today.Weekday()) - 1
	if daysSinceMonday < 0 {
		daysSinceMonday = 6
	}
	monday := today.AddDate(0, 0, -daysSinceMonday)
	mondayStr := monday.Format("2006-01-02")

	var groupID int
	err = tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test Holiday', 'Exceptional open day')
		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, $2, '09:00', '13:00', true)
	`, groupID, weekday)
	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, mondayStr)
	if err != nil {
		t.Fatalf("failed to seed application: %v", err)
	}

	// Call normal GetAvailableHours (no out_of_hours) - should return slots based on exceptional hours
	handler := http.HandlerFunc(GetAvailableHours)
	todayStr := today.Format("2006-01-02")
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr, nil)
	req = req.WithContext(ctx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("normal request failed: %d", w.Code)
	}
	var normalResponse []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &normalResponse)

	// Call with out_of_hours=true - should still generate 06:00-22:00 range
	req2 := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+todayStr+"&end="+todayStr+"&out_of_hours=true", nil)
	req2 = req2.WithContext(ctx)
	req2 = req2.WithContext(context.WithValue(req2.Context(), mw.UserRoleKey, "admin"))
	w2 := httptest.NewRecorder()
	handler.ServeHTTP(w2, req2)
	if w2.Code != http.StatusOK {
		t.Fatalf("out_of_hours request failed: %d", w2.Code)
	}
	var oohResponse []DayAvailableHours
	json.Unmarshal(w2.Body.Bytes(), &oohResponse)

	// Normal request should have 09:00-13:00 range (exceptional hours) or empty if closed
	// Out-of-hours request should have 06:00-22:00 range
	if len(oohResponse) > 0 {
		day := oohResponse[0]
		if !day.IsOpen {
			t.Error("expected out_of_hours to make day open")
		}
		if len(day.Slots) == 0 {
			t.Error("expected out_of_hours to generate slots")
		}
		// Verify we have pre-09:00 slots (6am-9am) which are only available in out_of_hours mode
		hasEarlySlot := false
		for _, slot := range day.Slots {
			if timeToMinutesForTest(slot.StartTime) < 540 { // before 09:00
				hasEarlySlot = true
				break
			}
		}
		if !hasEarlySlot {
			t.Error("expected out_of_hours slots before 09:00 (pre-exceptional-hours)")
		}
	}
}

// Helper to convert time string to minutes for test assertions
func timeToMinutesForTest(time string) int {
	parts := strings.Split(time, ":")
	if len(parts) < 2 {
		return 0
	}
	h, _ := strconv.Atoi(parts[0])
	m, _ := strconv.Atoi(parts[1])
	return h*60 + m
}

// TestScheduling_UpdateExceptionalApplications_Admin verifies that an
// admin can apply an exceptional hours group to specific weeks, activating
// holiday schedules for those periods.
func TestScheduling_UpdateExceptionalApplications_Admin(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	adminToken := jwt.GenerateAdminToken()

	// Create a group
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test Group', 'Test')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create group: %v", err)
	}

	handler := http.HandlerFunc(UpdateExceptionalApplications)

	reqBody := map[string]interface{}{
		"groupId":    groupID,
		"weekStarts": []string{"2026-03-02", "2026-03-09"},
	}

	w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", adminToken, reqBody, ctx)

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

	// Verify applications were created
	var count int
	err = tx.QueryRow(ctx, `
		SELECT COUNT(*) FROM exceptional_group_applications WHERE group_id = $1
	`, groupID).Scan(&count)
	if err != nil {
		t.Fatalf("failed to check applications: %v", err)
	}
	if count != 2 {
		t.Errorf("expected 2 applications, got %d", count)
	}
}

// TestScheduling_UpdateExceptionalApplications_NonAdmin verifies that
// non-admin users receive HTTP 403 when attempting to apply exceptional hours.
func TestScheduling_UpdateExceptionalApplications_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, _ := resetTestData(t)

	userToken := jwt.GenerateUserToken("user-123")
	handler := mw.RequireAuth(mw.RequireAdmin(http.HandlerFunc(UpdateExceptionalApplications)))

	reqBody := map[string]interface{}{
		"groupId":    1,
		"weekStarts": []string{"2026-03-02"},
	}

	w := makeAuthRequest(handler, "PUT", "/api/scheduling/exceptional-applications", userToken, reqBody, ctx)

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

// =============================================================================
// Time Blocker Tests for GetAvailableHours
// =============================================================================

// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin verifies that non-admin
// users do NOT see blocked time slots in their available hours.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
	blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
	_, err := tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'Staff Meeting', NULL)
	`, blockerTime)
	if err != nil {
		t.Fatalf("failed to create time blocker: %v", err)
	}

	// Make request as non-admin user
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)

	// Set non-admin context
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)

	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Fatal("expected at least one day in response")
	}

	// Find the day with the blocker (2026-03-16)
	var targetDay *DayAvailableHours
	for i := range response {
		if response[i].Date == "2026-03-16" {
			targetDay = &response[i]
			break
		}
	}

	if targetDay == nil {
		t.Fatal("expected day 2026-03-16 in response")
	}

	// Verify blocker is NOT visible in blockers field for non-admin
	if len(targetDay.Blockers) > 0 {
		t.Error("expected blockers field to be empty for non-admin users")
	}

	// Verify 10:00-11:00 slot is NOT available (subtracted due to blocker)
	for _, slot := range targetDay.Slots {
		if slot.StartTime == "10:00" {
			t.Error("expected 10:00 slot to be blocked and not available")
		}
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin verifies that admin users
// see blocked time slots subtracted from available slots AND visible in the
// blockers field for warning display.
func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
	blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
	_, err := tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'Staff Meeting', NULL)
	`, blockerTime)
	if err != nil {
		t.Fatalf("failed to create time blocker: %v", err)
	}

	// Make request as admin user
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-16&end=2026-03-16", nil)

	// Set admin context
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
	req = req.WithContext(reqCtx)

	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

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

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

	if len(response) == 0 {
		t.Fatal("expected at least one day in response")
	}

	// Find the day with the blocker (2026-03-16)
	var targetDay *DayAvailableHours
	for i := range response {
		if response[i].Date == "2026-03-16" {
			targetDay = &response[i]
			break
		}
	}

	if targetDay == nil {
		t.Fatal("expected day 2026-03-16 in response")
	}

	// Verify 10:00-11:00 slot is NOT available in slots (subtracted due to blocker)
	for _, slot := range targetDay.Slots {
		if slot.StartTime == "10:00" {
			t.Error("expected 10:00 slot to be blocked and not available for admin users")
		}
	}

	// Verify blocker IS visible in blockers field for admin (warning display)
	if len(targetDay.Blockers) == 0 {
		t.Error("expected blockers field to contain the blocker for admin users")
	} else {
		// Verify the blocker time range
		found := false
		for _, blocker := range targetDay.Blockers {
			if blocker.StartTime == "10:00" && blocker.EndTime == "11:00" {
				found = true
				break
			}
		}
		if !found {
			t.Error("expected blocker 10:00-11:00 in blockers field")
		}
	}
}

// --- Direct unit tests for isValidTime15Min ---

// TestIsValidTime15Min tests the time validation helper directly for all
// supported formats (HH:MM, HH:MM:SS) and edge cases.
func TestIsValidTime15Min(t *testing.T) {
	t.Parallel()
	tests := []struct {
		name  string
		time  string
		valid bool
	}{
		{"HH:MM 00", "09:00", true},
		{"HH:MM 15", "09:15", true},
		{"HH:MM 30", "09:30", true},
		{"HH:MM 45", "09:45", true},
		{"HH:MM :01", "09:01", false},
		{"HH:MM :07", "09:07", false},
		{"HH:MM :22", "09:22", false},
		{"HH:MM :59", "09:59", false},
		{"HH:MM:SS 00", "09:00:00", true},
		{"HH:MM:SS 15", "09:15:00", true},
		{"HH:MM:SS 30", "09:30:00", true},
		{"HH:MM:SS 45", "09:45:00", true},
		{"HH:MM:SS :01", "09:01:00", false},
		{"HH:MM:SS :07", "09:07:00", false},
		{"HH:MM:SS :22", "09:22:00", false},
		{"HH:MM:SS :59", "09:59:00", false},
		{"single-digit hour valid", "9:00", true},
		{"single-digit hour invalid", "9:07", false},
		{"midnight 00:00", "00:00", true},
		{"midnight 00:00:00", "00:00:00", true},
		{"empty string", "", false},
		{"not a time", "abc", false},
		{"single colon only", ":", false},
		{"only colon numbers", ":15", false},
		{"extra parts", "09:00:00:00", false},
		{"hour only", "09", false},
		{"garbage after colon", "09:xx", false},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := isValidTime15Min(tt.time)
			if got != tt.valid {
				if tt.valid {
					t.Errorf("isValidTime15Min(%q) = false, want true", tt.time)
				} else {
					t.Errorf("isValidTime15Min(%q) = true, want false", tt.time)
				}
			}
		})
	}
}

// =============================================================================
// Exhaustive Admin Time Blocker Tests
// =============================================================================
//
// These tests verify that time blockers are subtracted from available slots for
// ALL users (including admins), preventing 409 "blocked" errors on reserve.
// Blockers are also surfaced in the blockers field for admin warning display.

// makeAdminAvailableHoursRequest is a helper that sends a GET to
// /api/scheduling/available-hours as an admin user and returns the response.
func makeAdminAvailableHoursRequest(t *testing.T, ctx context.Context, start, end string) []DayAvailableHours {
	t.Helper()
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start="+start+"&end="+end, nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
		t.Fatalf("failed to unmarshal response: %v", err)
	}
	return response
}

// findDayByDate finds a DayAvailableHours by date string in the response.
func findDayByDate(response []DayAvailableHours, date string) *DayAvailableHours {
	for i := range response {
		if response[i].Date == date {
			return &response[i]
		}
	}
	return nil
}

// slotExists checks if a time falls within any available slot range.
// Slots are continuous ranges (e.g. {StartTime:09:00, EndTime:17:00}) and the
// frontend generates 15-min intervals from them. A time is "available" if it
// falls at or after a slot's StartTime and before its EndTime.
func slotExists(slots []TimeSlot, time string) bool {
	for _, s := range slots {
		if time >= s.StartTime && time < s.EndTime {
			return true
		}
	}
	return false
}

// getWorkingHoursForDate queries the working hours for the given date via the
// GetWorkingHours handler, using the admin context so the returned context works
// with the test transaction. Returns the hours for the specified date, or empty
// strings / false if the day is closed.
func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (start, end string, isOpen bool) {
	t.Helper()
	handler := http.HandlerFunc(GetWorkingHours)
	req := httptest.NewRequest("GET", "/api/scheduling/working-hours?start="+date+"&end="+date, nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("GetWorkingHours returned %d: %s", w.Code, w.Body.String())
	}
	type whDay struct {
		Date      string `json:"date"`
		IsOpen    bool   `json:"isOpen"`
		StartTime string `json:"startTime"`
		EndTime   string `json:"endTime"`
	}
	var days []whDay
	if err := json.Unmarshal(w.Body.Bytes(), &days); err != nil {
		t.Fatalf("failed to unmarshal working hours: %v", err)
	}
	for _, d := range days {
		if d.Date == date {
			return d.StartTime, d.EndTime, d.IsOpen
		}
	}
	t.Fatalf("date %s not found in working hours response", date)
	return "", "", false
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers tests
// that multiple time blockers on the same day are ALL subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00):
	// 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training)
	b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, b1)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Training', NULL)`, b2)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// Both blocked slots should be absent from available slots
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot to be blocked (Staff Meeting)")
	}
	if slotExists(targetDay.Slots, "14:00") {
		t.Error("expected 14:00 slot to be blocked (Training)")
	}

	// Non-blocked slots should still be available (e.g. 09:00, 11:00, 13:00, 15:00)
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 slot to remain available")
	}
	if !slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 slot to remain available after 10-11 blocker")
	}
	if !slotExists(targetDay.Slots, "15:00") {
		t.Error("expected 15:00 slot to remain available after 14-15 blocker")
	}

	// Both blockers visible in blockers field for admin warning display
	if len(targetDay.Blockers) != 2 {
		t.Errorf("expected 2 blockers, got %d", len(targetDay.Blockers))
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking tests
// that both time blockers AND existing bookings are subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 (open 09:00-17:00)
	// Create a booking at 11:00-12:00 and a blocker at 14:00-15:00
	bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
	bookingEnd := bookingStart.Add(60 * time.Minute)
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)
	_, err = tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
		VALUES ($1, $2, 'confirmed', 60, $3)
	`, userID, bookingStart, bookingEnd)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}

	blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Equipment Maintenance', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// 11:00 slot removed by booking, 14:00 slot removed by blocker
	if slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 slot to be unavailable (booking)")
	}
	if slotExists(targetDay.Slots, "14:00") {
		t.Error("expected 14:00 slot to be blocked (Equipment Maintenance)")
	}

	// Unaffected slots remain
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 to remain available")
	}
	if !slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 to remain available")
	}

	// Only the blocker (not the booking) appears in blockers field
	foundBlocker := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "14:00" && b.EndTime == "15:00" {
			foundBlocker = true
		}
	}
	if !foundBlocker {
		t.Error("expected blocker 14:00-15:00 in blockers field")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker tests that
// a blocker covering the entire open period leaves no slots available for admin.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period
	blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// All slots should be removed
	if len(targetDay.Slots) > 0 {
		t.Errorf("expected no available slots with all-day blocker, got %d slots: %+v", len(targetDay.Slots), targetDay.Slots)
	}

	// Blocker is visible in blockers field
	if len(targetDay.Blockers) == 0 {
		t.Error("expected all-day blocker in blockers field")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker
// tests that a blocker outside open hours does NOT affect admin slot availability.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close)
	blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// All normal slots should still be present
	expectedSlots := []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"}
	for _, expected := range expectedSlots {
		if !slotExists(targetDay.Slots, expected) {
			t.Errorf("expected slot %s to remain available (non-overlapping blocker)", expected)
		}
	}

	// After-hours blocker still visible in blockers field
	found := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "17:00" && b.EndTime == "18:00" {
			found = true
		}
	}
	if !found {
		t.Error("expected after-hours blocker in blockers field")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay tests that
// blockers on multiple days are independently subtracted for admin users.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00
	b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Meeting', NULL)`, b1)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Wed Training', NULL)`, b2)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
	tue := findDayByDate(response, "2026-03-17")
	wed := findDayByDate(response, "2026-03-18")

	if tue == nil {
		t.Fatal("expected 2026-03-17 in response")
	}
	if wed == nil {
		t.Fatal("expected 2026-03-18 in response")
	}

	// Tuesday: 10:00 slot blocked
	if slotExists(tue.Slots, "10:00") {
		t.Error("expected 2026-03-17 10:00 slot to be blocked")
	}
	// Wednesday: 14:00 slot blocked
	if slotExists(wed.Slots, "14:00") {
		t.Error("expected 2026-03-18 14:00 slot to be blocked")
	}
	// Tuesday: 14:00 still available
	if !slotExists(tue.Slots, "14:00") {
		t.Error("expected 2026-03-17 14:00 to remain available")
	}
	// Wednesday: 10:00 still available
	if !slotExists(wed.Slots, "10:00") {
		t.Error("expected 2026-03-18 10:00 to remain available")
	}

	// Both days have blockers in blockers field
	if len(tue.Blockers) != 1 {
		t.Errorf("expected 1 blocker on Tuesday, got %d", len(tue.Blockers))
	}
	if len(wed.Blockers) != 1 {
		t.Errorf("expected 1 blocker on Wednesday, got %d", len(wed.Blockers))
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart tests that
// a blocker at the exact opening time correctly removes the first slot.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day)
	blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// 09:00 slot should be blocked
	if slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 slot to be blocked (boundary start)")
	}
	// 10:00 slot should be available (blocker is 09-10, ends at 10:00)
	if !slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot to be available (starts after blocker ends)")
	}
	// Blocker visible in blockers field
	found := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "09:00" && b.EndTime == "10:00" {
			found = true
		}
	}
	if !found {
		t.Error("expected blocker 09:00-10:00 in blockers field")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd tests that
// a blocker at the exact closing time correctly removes the last slot.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day)
	blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// 16:00 slot should be blocked
	if slotExists(targetDay.Slots, "16:00") {
		t.Error("expected 16:00 slot to be blocked (boundary end)")
	}
	// 15:00 slot should be available
	if !slotExists(targetDay.Slots, "15:00") {
		t.Error("expected 15:00 slot to be available (ends before blocker starts)")
	}
	// Blocker visible in blockers field
	found := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "16:00" && b.EndTime == "17:00" {
			found = true
		}
	}
	if !found {
		t.Error("expected blocker 16:00-17:00 in blockers field")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours verifies that
// blockers are still subtracted from admin slots when out_of_hours mode is active.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 — blocker at 10:00-11:00
	blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime)

	// Request with out_of_hours=true (extends to 06:00-22:00 for admin)
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17&out_of_hours=true", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// 10:00 slot should be blocked even in out_of_hours mode
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot to be blocked in out_of_hours mode")
	}
	// 07:00 slot (extended hours) should be available (no blocker there)
	if !slotExists(targetDay.Slots, "07:00") {
		t.Error("expected 07:00 slot to be available in out_of_hours mode")
	}
	// Blocker visible in blockers field
	if len(targetDay.Blockers) == 0 {
		t.Error("expected blocker in blockers field for out_of_hours admin")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression verifies that
// non-admin users still have blockers correctly subtracted (regression check).
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) {
	t.Parallel()
	ctx, tx := resetTestData(t)

	// Tuesday 2026-03-17 — blocker at 10:00-11:00
	blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime)

	// Request as non-admin
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-17 in response")
	}

	// 10:00 slot blocked for non-admin
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot to be blocked for non-admin user")
	}
	// 09:00 still available
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 slot to remain available for non-admin user")
	}
	// Non-admin should NOT see blockers in blockers field
	if len(targetDay.Blockers) != 0 {
		t.Error("expected blockers field to be empty for non-admin user")
	}
}

// =============================================================================
// Gap-Filling Tests — Blockers with Recurring, Reservations, Overlaps, etc.
// =============================================================================

// TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring verifies that a
// recurring (cron-based) time blocker is expanded and subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T) {
	// No t.Parallel() — GetAvailableHours cleanup operations can deadlock with
	// concurrent test transactions on the shared test database.
	ctx, tx := resetTestData(t)

	// Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16
	startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
	cronExpr := "0 12 * * *" // Every day at 12:00
	tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
		VALUES ($1, 60, 'Daily Lunch Blocker', $2, NULL)
	`, startTime, cronExpr)

	// Query Tue 2026-03-17 (open 09:00-17:00) and Wed 2026-03-18 (open 09:00-17:00)
	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
	tue := findDayByDate(response, "2026-03-17")
	wed := findDayByDate(response, "2026-03-18")
	if tue == nil || wed == nil {
		t.Fatal("expected both days in response")
	}

	// 12:00 slot should be blocked on both days
	if slotExists(tue.Slots, "12:00") {
		t.Error("expected 12:00 blocked on Tuesday (recurring blocker)")
	}
	if slotExists(wed.Slots, "12:00") {
		t.Error("expected 12:00 blocked on Wednesday (recurring blocker)")
	}
	// Adjacent slots should be available
	if !slotExists(tue.Slots, "11:00") {
		t.Error("expected 11:00 available on Tuesday")
	}
	if !slotExists(wed.Slots, "13:00") {
		t.Error("expected 13:00 available on Wednesday")
	}
	// Both days should show blocker in blockers field
	if len(tue.Blockers) == 0 {
		t.Error("expected blocker visible in blockers field on Tuesday")
	}
	if len(wed.Blockers) == 0 {
		t.Error("expected blocker visible in blockers field on Wednesday")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation verifies that
// RESERVATION:admin time_blocker entries are also subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Create a real admin user to satisfy FK constraint, then simulate a reservation
	adminID, err := fixtures.CreateTestAdminUser(tx)
	if err != nil {
		t.Fatalf("failed to create admin user: %v", err)
	}
	defer fixtures.DeleteUser(tx, adminID)

	reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	if _, err := tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
	`, reservationTime, adminID); err != nil {
		t.Fatalf("failed to create reservation blocker: %v", err)
	}

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	// 10:00 slot should be blocked by the reservation
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot blocked by admin reservation")
	}
	// 09:00 and 11:00 should still be available
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 to remain available")
	}
	if !slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 to remain available")
	}
	// Reservation should be visible in blockers field
	if len(targetDay.Blockers) == 0 {
		t.Error("expected reservation in blockers field for admin")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin verifies
// that RESERVATION entries are also subtracted for non-admin users.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) {
	// Not parallel (see above)
	ctx, tx := resetTestData(t)

	reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001')
	`, reservationTime)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 slot blocked by reservation for non-admin")
	}
	if len(targetDay.Blockers) != 0 {
		t.Error("expected blockers field empty for non-admin")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers verifies
// that two time blockers that overlap each other are both correctly subtracted.
// Two gaps applied in sequence are equivalent to their union.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00
	b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Long Morning Meeting', NULL)`, b1)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Extended Training', NULL)`, b2)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	// The union of [10-12] and [11-13] is [10-13]. Verify key points.
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 blocked (overlapping blockers)")
	}
	if slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 blocked")
	}
	if slotExists(targetDay.Slots, "12:00") {
		t.Error("expected 12:00 blocked")
	}
	// 09:00 (before) and 13:00 (after) should remain
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 to remain available")
	}
	if !slotExists(targetDay.Slots, "13:00") {
		t.Error("expected 13:00 to remain available")
	}
	// Both blockers visible
	if len(targetDay.Blockers) != 2 {
		t.Errorf("expected 2 blockers in blockers field, got %d", len(targetDay.Blockers))
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries verifies
// that a booking ending exactly when a blocker starts (and vice versa) does NOT
// cause false overlap — no slot is removed beyond the exact boundaries.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent)
	bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	bookingEnd := bookingStart.Add(60 * time.Minute)
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)
	tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
		VALUES ($1, $2, 'confirmed', 60, $3)
	`, userID, bookingStart, bookingEnd)

	blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Adjacent Blocker', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	// Both 10:00 and 11:00 should be unavailable — 10:00 removed by booking,
	// 11:00 removed by blocker. But there should be a slot 09:00-10:00 and a
	// slot 12:00-17:00.
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 unavailable (booking)")
	}
	if slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 unavailable (blocker)")
	}
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 available")
	}
	if !slotExists(targetDay.Slots, "12:00") {
		t.Error("expected 12:00 available")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker verifies that
// a blocker spanning multiple working hours correctly subtracts available slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Use the first open day found and the following day
	tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
	wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
	if !tueOpen || !wedOpen {
		t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
	}

	// Blocker at 1 hour before close on Tuesday
	tueBlockHour := mustParseHour(tueEnd) - 1
	tueBlockStart := fmt.Sprintf("%02d:00", tueBlockHour)
	tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End-of-day blocker', NULL)`, tueBlockTime)

	// Blocker at opening on Wednesday (first 2 hours)
	wedBlockStart := wedStart
	wedBlockEnd := fmt.Sprintf("%02d:00", mustParseHour(wedStart)+2)
	wedBlockDur := 120
	wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, 'Opening blocker', NULL)`, wedBlockTime, wedBlockDur)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
	tue := findDayByDate(response, "2026-03-17")
	wed := findDayByDate(response, "2026-03-18")
	if tue == nil || wed == nil {
		t.Fatal("expected both days in response")
	}

	// Tuesday: blocker at end of day removes the last hour
	if slotExists(tue.Slots, tueBlockStart) {
		t.Errorf("expected %s blocked on Tuesday (end-of-day blocker)", tueBlockStart)
	}
	// The hour before the blocker should be available
	hourBefore := fmt.Sprintf("%02d:00", mustParseHour(tueBlockStart)-1)
	if hourBefore >= tueStart && !slotExists(tue.Slots, hourBefore) {
		t.Errorf("expected %s available on Tuesday (before block)", hourBefore)
	}

	// Wednesday: blocker at opening removes first 2 hours
	if slotExists(wed.Slots, wedBlockStart) {
		t.Errorf("expected %s blocked on Wednesday (opening blocker)", wedBlockStart)
	}
	// The hour after the blocker should be available
	if !slotExists(wed.Slots, wedBlockEnd) {
		t.Errorf("expected %s available on Wednesday (after block)", wedBlockEnd)
	}
}

// mustParseHour extracts the hour from a "HH:MM" or "HH:MM:SS" time string.
func mustParseHour(t string) int {
	if len(t) >= 2 {
		var h int
		fmt.Sscanf(t, "%d", &h)
		return h
	}
	return 0
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers verifies admin
// sees all normal slots when no time blockers exist.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T) {

	ctx, _ := resetTestData(t)

	// Tue 2026-03-17 — no blockers
	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	// All standard slots should be present
	for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00"} {
		if !slotExists(targetDay.Slots, start) {
			t.Errorf("expected slot %s to be available (no blockers)", start)
		}
	}
	// No blockers in field
	if len(targetDay.Blockers) != 0 {
		t.Error("expected empty blockers field when no blockers exist")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker tests that
// a time blocker on a closed day does NOT create any phantom slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Sunday 2026-03-22 is closed. Blocker at 10:00-11:00.
	blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-22", "2026-03-22")
	targetDay := findDayByDate(response, "2026-03-22")
	if targetDay == nil {
		t.Fatal("expected 2026-03-22 in response")
	}

	if targetDay.IsOpen {
		t.Error("expected Sunday to be closed")
	}
	if len(targetDay.Slots) != 0 {
		t.Error("expected no slots on closed day even with blocker")
	}
	// Blockers on closed days are not populated (the day is closed, so
	// the blocker is irrelevant; no slots exist to subtract from).
	if len(targetDay.Blockers) != 0 {
		t.Error("expected no blockers listed on closed day (day is closed)")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial verifies
// that querying a multi-day range where only some days have blockers correctly
// leaves unblocked days unaffected.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Blocker only on Tuesday (2026-03-17) at 10:00-11:00
	blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime)

	// Query Tue-Thu (17th, 18th, 19th)
	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-19")
	tue := findDayByDate(response, "2026-03-17")
	wed := findDayByDate(response, "2026-03-18")
	thu := findDayByDate(response, "2026-03-19")
	if tue == nil || wed == nil || thu == nil {
		t.Fatal("expected all three days in response")
	}

	// Tuesday: 10:00 blocked
	if slotExists(tue.Slots, "10:00") {
		t.Error("expected 10:00 blocked on Tuesday")
	}
	if len(tue.Blockers) == 0 {
		t.Error("expected blocker visible on Tuesday")
	}

	// Wednesday: all slots available, no blockers
	for _, start := range []string{"09:00", "10:00", "11:00", "14:00"} {
		if !slotExists(wed.Slots, start) {
			t.Errorf("expected %s available on Wednesday (no blocker)", start)
		}
	}
	if len(wed.Blockers) != 0 {
		t.Error("expected no blockers on Wednesday")
	}

	// Thursday (open 09:00-17:00): all slots available, no blockers
	for _, start := range []string{"09:00", "10:00", "11:00", "12:00", "14:00", "16:00"} {
		if !slotExists(thu.Slots, start) {
			t.Errorf("expected %s available on Thursday (no blocker)", start)
		}
	}
	if len(thu.Blockers) != 0 {
		t.Error("expected no blockers on Thursday")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours verifies
// that blockers still subtract from slots on days with exceptional working hours.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00.
	// Also add a blocker at 12:00-13:00.
	// First create the exceptional group
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Test Exceptional Group', 'Test for blocker on exceptional hours day')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional group: %v", err)
	}
	// Add exceptional hours: Monday (weekday 0) 10:00-16:00
	tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, 0, '10:00:00', '16:00:00', true)
	`, groupID)
	// Apply to week containing 2026-03-16
	tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, '2026-03-16')
	`, groupID)

	// Blocker on Monday 12:00-13:00
	blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Lunch Break', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-16", "2026-03-16")
	targetDay := findDayByDate(response, "2026-03-16")
	if targetDay == nil {
		t.Fatal("expected 2026-03-16 in response")
	}

	if !targetDay.IsOpen {
		t.Error("expected Monday to be open (exceptional hours)")
	}
	// 12:00 blocked by blocker
	if slotExists(targetDay.Slots, "12:00") {
		t.Error("expected 12:00 blocked on exceptional hours day")
	}
	// 10:00 and 14:00 should be available
	if !slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 available on exceptional hours day")
	}
	if !slotExists(targetDay.Slots, "14:00") {
		t.Error("expected 14:00 available on exceptional hours day")
	}
	// Blocker visible
	if len(targetDay.Blockers) == 0 {
		t.Error("expected blocker visible on exceptional hours day")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay tests that
// a blocker on a closed day has no effect for non-admin users (day stays closed).
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) {

	ctx, tx := resetTestData(t)

	// Sunday 2026-03-22 closed, blocker at 10:00-11:00
	blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-22&end=2026-03-22", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-22")
	if targetDay == nil {
		t.Fatal("expected 2026-03-22 in response")
	}

	if targetDay.IsOpen {
		t.Error("expected Sunday to be closed for non-admin")
	}
	if len(targetDay.Slots) != 0 {
		t.Error("expected no slots on closed day")
	}
	if len(targetDay.Blockers) != 0 {
		t.Error("expected blockers field empty for non-admin")
	}
}

// TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent verifies
// that a booking and blocker that are adjacent (booking 10-11, blocker 11-12)
// correctly list two separate unavailable ranges for non-admin users.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) {

	ctx, tx := resetTestData(t)

	bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	bookingEnd := bookingStart.Add(60 * time.Minute)
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)
	tx.Exec(ctx, `
		INSERT INTO bookings (user_id, start_time, status, total_duration_minutes, end_time)
		VALUES ($1, $2, 'confirmed', 60, $3)
	`, userID, bookingStart, bookingEnd)

	blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Maintenance Window', NULL)`, blockerTime)

	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, "user001")
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)
	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 unavailable (booking)")
	}
	if slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 unavailable (blocker)")
	}
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 available")
	}
	if !slotExists(targetDay.Slots, "12:00") {
		t.Error("expected 12:00 available")
	}
	if len(targetDay.Blockers) != 0 {
		t.Error("expected blockers field empty for non-admin")
	}
}

// normalizeTime unit tests

func TestNormalizeTime_StripsSeconds(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"HH:MM:SS", "09:00:00", "09:00"},
		{"already HH:MM", "09:00", "09:00"},
		{"empty string", "", ""},
		{"single digit hour stripped", "9:00:00", "09:00"},
		{"midnight", "00:00:00", "00:00"},
		{"23:59:59", "23:59:59", "23:59"},
		{"12:30:45", "12:30:45", "12:30"},
		{"malformed no colon", "0900", "0900"},
		{"single colon", "09:00", "09:00"},
		{"extra suffix", "09:00:00:extra", "09:00"},
		{"short string", "9:00", "09:00"},
		{"minimal HH:MM:SS", "1:2:3", "01:02"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeTime(tt.input)
			if got != tt.expected {
				t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

func TestNormalizeTime_NoChangeForInvalidFormats(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"purely invalid", "invalid", "invalid"},
		// "25:00:00" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
		{"invalid time with pattern", "25:00:00", "25:00"},
		// "ab:cd:ef" has ':' at positions 2 and 5 so normalizeTime DOES strip seconds
		{"non-numeric with pattern", "ab:cd:ef", "ab:cd"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeTime(tt.input)
			if got != tt.expected {
				t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

// Late-night lock boundary tests

func TestLateNightLock_After22_BlocksNextMorning(t *testing.T) {
	available := []TimeSlot{{StartTime: "06:00", EndTime: "22:00"}}
	lateBlock := TimeSlot{StartTime: "00:00", EndTime: "11:00"}
	result := subtractTimeSlots(available, []TimeSlot{lateBlock})
	expected := []TimeSlot{{StartTime: "11:00", EndTime: "22:00"}}
	if len(result) != len(expected) {
		t.Fatalf("expected %d slot, got %d: %+v", len(expected), len(result), result)
	}
	if result[0].StartTime != expected[0].StartTime || result[0].EndTime != expected[0].EndTime {
		t.Errorf("expected slot %+v, got %+v", expected[0], result[0])
	}
}

func TestLateNightLock_Before22_NoBlock(t *testing.T) {
	available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
	result := subtractTimeSlots(available, nil)
	if len(result) != 1 {
		t.Fatalf("expected 1 slot unchanged, got %d: %+v", len(result), result)
	}
}

func TestSubtractTimeSlots_NoOverlap(t *testing.T) {
	available := []TimeSlot{
		{StartTime: "09:00", EndTime: "12:00"},
		{StartTime: "13:00", EndTime: "17:00"},
	}
	gaps := []TimeSlot{{StartTime: "12:00", EndTime: "13:00"}}
	result := subtractTimeSlots(available, gaps)
	if len(result) != 2 {
		t.Fatalf("expected 2 slots unchanged, got %d: %+v", len(result), result)
	}
}

func TestSubtractTimeSlots_MultipleGaps(t *testing.T) {
	available := []TimeSlot{{StartTime: "09:00", EndTime: "18:00"}}
	gaps := []TimeSlot{
		{StartTime: "11:00", EndTime: "12:00"},
		{StartTime: "14:00", EndTime: "15:00"},
	}
	result := subtractTimeSlots(available, gaps)
	expected := []TimeSlot{
		{StartTime: "09:00", EndTime: "11:00"},
		{StartTime: "12:00", EndTime: "14:00"},
		{StartTime: "15:00", EndTime: "18:00"},
	}
	if len(result) != 3 {
		t.Fatalf("expected 3 slots, got %d: %+v", len(result), result)
	}
	for i, exp := range expected {
		if result[i].StartTime != exp.StartTime || result[i].EndTime != exp.EndTime {
			t.Errorf("slot %d: expected %+v, got %+v", i, exp, result[i])
		}
	}
}

func TestSubtractTimeSlots_GapEatsWholeSlot(t *testing.T) {
	available := []TimeSlot{{StartTime: "09:00", EndTime: "12:00"}}
	gaps := []TimeSlot{{StartTime: "08:00", EndTime: "13:00"}}
	result := subtractTimeSlots(available, gaps)
	if len(result) != 0 {
		t.Errorf("expected 0 slots, got %d: %+v", len(result), result)
	}
}

func TestSubtractTimeSlots_GapPartialOverlap(t *testing.T) {
	available := []TimeSlot{{StartTime: "09:00", EndTime: "17:00"}}
	gaps := []TimeSlot{{StartTime: "08:00", EndTime: "10:00"}}
	result := subtractTimeSlots(available, gaps)
	if len(result) != 1 {
		t.Fatalf("expected 1 slot, got %d: %+v", len(result), result)
	}
	if result[0].StartTime != "10:00" || result[0].EndTime != "17:00" {
		t.Errorf("expected slot 10:00-17:00, got %+v", result[0])
	}
}

func TestNormalizeTime_SingleDigitHour(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"single digit hour with seconds", "9:00:00", "09:00"},
		{"single digit hour no seconds", "9:00", "09:00"},
		{"double digit hour with seconds", "09:00:00", "09:00"},
		{"double digit hour no seconds", "09:00", "09:00"},
		{"single digit min with seconds", "09:5:00", "09:05"},
		{"hour only no colon", "0900", "0900"},
		{"empty string", "", ""},
		{"midnight with seconds", "00:00:00", "00:00"},
		{"end of day with seconds", "23:59:59", "23:59"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeTime(tt.input)
			if got != tt.expected {
				t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

func TestNormalizeTime_NoChangeForEdgeCases(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"no colons", "hello", "hello"},
		{"single colon only", ":", ":"},
		{"trailing colon", "09:", "09:"},
		{"only two chars after colon", "9:0", "09:00"},
		{"three colons no trailing pair", "a:b:c:d", "a:b"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeTime(tt.input)
			if got != tt.expected {
				t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) {
	// These are the actual formats returned by the working hours and blocker
	// queries. normaliseTime is called on baseStart and baseEnd in
	// GetAvailableHours to ensure string comparison consistency.
	realWorld := map[string]string{
		"09:00:00": "09:00",
		"17:00:00": "17:00",
		"09:00":    "09:00",
		"17:00":    "17:00",
		"00:00:00": "00:00",
		"22:00:00": "22:00",
	}
	for input, expected := range realWorld {
		got := normalizeTime(input)
		if got != expected {
			t.Errorf("normalizeTime(%q) = %q, want %q", input, got, expected)
		}
	}
}

// TestScheduling_GetAvailableHours_CrossDayBlocker verifies that a single time
// blocker with a duration spanning multiple calendar days correctly subtracts
// slots from each affected day.
func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Use Tue 2026-03-17 and Wed 2026-03-18 — both open days
	_, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
	_, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
	if !tueOpen || !wedOpen {
		t.Skip("test requires both Tue 2026-03-17 and Wed 2026-03-18 to be open days")
	}

	// Blocker starts at 15:00 on Tuesday and lasts 20 hours (covers all of
	// Wednesday's working hours up to 11:00).
	blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 1200, 'Multi-day blocker', NULL)`, blockerStart)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
	tue := findDayByDate(response, "2026-03-17")
	wed := findDayByDate(response, "2026-03-18")
	if tue == nil || wed == nil {
		t.Fatal("expected both days in response")
	}

	// Tuesday: blocker removes 15:00 through end of day (17:00)
	if slotExists(tue.Slots, "15:00") {
		t.Errorf("expected 15:00 blocked on Tuesday (cross-day blocker starts)")
	}
	// Slots before 15:00 should remain
	if !slotExists(tue.Slots, "09:00") {
		t.Error("expected 09:00 available on Tuesday")
	}
	if !slotExists(tue.Slots, "10:00") {
		t.Error("expected 10:00 available on Tuesday")
	}
	// End-of-day slot after blocker start should be gone
	if mustParseHour(tueEnd) > 15 {
		lastSlot := fmt.Sprintf("%02d:00", mustParseHour(tueEnd)-2)
		if slotExists(tue.Slots, lastSlot) {
			t.Errorf("expected end-of-day slot %s blocked on Tuesday", lastSlot)
		}
	}

	// Wednesday: blocker still active until 11:00 — removes morning slots
	for _, start := range []string{"09:00", "10:00"} {
		if slotExists(wed.Slots, start) {
			t.Errorf("expected %s blocked on Wednesday (cross-day blocker spillover)", start)
		}
	}
	// 11:00 onwards should be available
	if !slotExists(wed.Slots, "11:00") {
		t.Errorf("expected 11:00 available on Wednesday (after cross-day blocker ends)")
	}
	if !slotExists(wed.Slots, "12:00") {
		t.Errorf("expected 12:00 available on Wednesday")
	}
}

// =============================================================================
// DST Transition Tests
// =============================================================================
//
// These tests verify that timezone handling is correct during BST (summer)
// when the wall-clock time differs from UTC by +1 hour.

// TestScheduling_DST_BlockerTimeFormatting verifies that blocker times are
// formatted in Europe/London during BST, so the blocker correctly subtracts
// wall-clock slots. A blocker at 15:00 BST (= 14:00 UTC) should block the
// 15:00-16:00 BST slot, not 14:00-15:00 BST.
func TestScheduling_DST_BlockerTimeFormatting(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Monday 2026-06-15 is in BST (UTC+1). Working hours: 09:00-17:00.
	// Insert a blocker at 15:00 BST = 14:00 UTC.
	blockerTime := time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC)
	_, err := tx.Exec(ctx, `
		INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
		VALUES ($1, 60, 'DST Blocker', NULL)
	`, blockerTime)
	if err != nil {
		t.Fatalf("failed to create DST blocker: %v", err)
	}

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
	targetDay := findDayByDate(response, "2026-06-15")
	if targetDay == nil {
		t.Fatal("expected day 2026-06-15 in response")
	}

	// 15:00 BST slot should be blocked (wall-clock time)
	if slotExists(targetDay.Slots, "15:00") {
		t.Error("expected 15:00 BST slot to be blocked by DST blocker")
	}
	// 14:00 BST slot should still be available (blocker starts at 15:00)
	if !slotExists(targetDay.Slots, "14:00") {
		t.Error("expected 14:00 BST slot to remain available")
	}
	// 16:00 BST slot should be available (blocker ends at 16:00)
	if !slotExists(targetDay.Slots, "16:00") {
		t.Error("expected 16:00 BST slot to be available after blocker")
	}

	// Verify blocker appears in blockers field with correct wall-clock times
	found := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "15:00" && b.EndTime == "16:00" {
			found = true
			break
		}
	}
	if !found {
		t.Error("expected blocker 15:00-16:00 in blockers field (BST wall-clock time)")
	}
}

// TestScheduling_DST_MultipleBlockers verifies multiple blockers during BST
// are all correctly applied to wall-clock time slots.
func TestScheduling_DST_MultipleBlockers(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Monday 2026-06-15 BST: two blockers at 10:00 BST (= 09:00 UTC)
	// and 14:00 BST (= 13:00 UTC).
	b1 := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC)
	b2 := time.Date(2026, 6, 15, 13, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning blocker', NULL)`, b1)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Afternoon blocker', NULL)`, b2)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
	targetDay := findDayByDate(response, "2026-06-15")
	if targetDay == nil {
		t.Fatal("expected day 2026-06-15 in response")
	}

	// Morning blocker at 10:00 BST
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 BST slot blocked (morning blocker)")
	}
	// Afternoon blocker at 14:00 BST
	if slotExists(targetDay.Slots, "14:00") {
		t.Error("expected 14:00 BST slot blocked (afternoon blocker)")
	}
	// 09:00 should be available (before any blocker)
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 BST to remain available")
	}
	// 11:00 should be available (between blockers)
	if !slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 BST to remain available")
	}
	// 15:00 should be available (after both blockers)
	if !slotExists(targetDay.Slots, "15:00") {
		t.Error("expected 15:00 BST to remain available")
	}

	// Both blockers visible in wall-clock time
	found1, found2 := false, false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "10:00" && b.EndTime == "11:00" {
			found1 = true
		}
		if b.StartTime == "14:00" && b.EndTime == "15:00" {
			found2 = true
		}
	}
	if !found1 {
		t.Error("expected morning blocker 10:00-11:00 in blockers field")
	}
	if !found2 {
		t.Error("expected afternoon blocker 14:00-15:00 in blockers field")
	}
}

// TestScheduling_DST_BlockerOnSpringForward verifies time blocker handling
// during the March BST transition (clocks spring forward 01:00→02:00).
// 2026-03-29 is the spring-forward date. The blocker is placed within
// working hours (10:00-11:00 BST) to verify it correctly blocks wall-clock
// time on the transition day.
func TestScheduling_DST_BlockerOnSpringForward(t *testing.T) {
	ctx, tx := resetTestData(t)

	// 2026-03-29 is the BST start date (clocks spring forward). Sunday is closed
	// by default. Use an exceptional hours override to open 09:00-17:00.
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Spring Forward Test', 'Test BST transition on 2026-03-29')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional group: %v", err)
	}
	tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, 6, '09:00:00', '17:00:00', true)
	`, groupID)
	tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, '2026-03-23')
	`, groupID)

	// Blocker at 10:00 BST on 2026-03-29 = 09:00 UTC (spring-forward day,
	// clocks jump 01:00→02:00, so 10:00 BST = 09:00 UTC as usual).
	blockerTime := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC)
	tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Spring Forward Blocker', NULL)`, blockerTime)

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-29", "2026-03-29")
	targetDay := findDayByDate(response, "2026-03-29")
	if targetDay == nil {
		t.Fatal("expected day 2026-03-29 in response")
	}

	if !targetDay.IsOpen {
		t.Fatal("expected 2026-03-29 to be open (exceptional hours)")
	}

	// 10:00 BST slot should be blocked (wall-clock time within working hours)
	if slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 BST slot to be blocked (spring-forward blocker)")
	}
	// 09:00 BST should be available (before blocker)
	if !slotExists(targetDay.Slots, "09:00") {
		t.Error("expected 09:00 BST to be available before blocker")
	}
	// 11:00 BST should be available (after blocker ends)
	if !slotExists(targetDay.Slots, "11:00") {
		t.Error("expected 11:00 BST to be available after blocker")
	}

	// Verify blocker in wall-clock time
	found := false
	for _, b := range targetDay.Blockers {
		if b.StartTime == "10:00" && b.EndTime == "11:00" {
			found = true
			break
		}
	}
	if !found {
		t.Error("expected blocker 10:00-11:00 in blockers field (spring-forward wall-clock)")
	}
}

// TestNormalizeTime_NonNumericInput verifies that normalizeTime does not
// silently pad non-numeric single-character segments (defensive guard).
func TestNormalizeTime_NonNumericInput(t *testing.T) {
	tests := []struct {
		name     string
		input    string
		expected string
	}{
		{"non-numeric hour single char", "a:00", "a:00"},
		{"non-numeric minute single char", "09:b", "09:b"},
		{"both non-numeric single char", "a:b", "a:b"},
		{"numeric still works", "9:00", "09:00"},
		{"single digit minute", "09:5", "09:05"},
		{"both single digit", "9:5", "09:05"},
		{"non-numeric multi-char hour passes through", "ab:00", "ab:00"},
		{"non-numeric with seconds stripped", "a:b:c", "a:b"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := normalizeTime(tt.input)
			if got != tt.expected {
				t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
			}
		})
	}
}

// TestScheduling_DST_DateBoundary verifies that GetAvailableHours includes
// BST early-morning bookings (00:00-00:59 BST = 23:00-23:59 UTC previous day)
// in the correct date range. Uses londonLocation for date boundaries.
func TestScheduling_DST_DateBoundary(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Monday 2026-06-15 is BST. Create a booking at 00:30 BST (= 23:30 UTC June 14).
	// This booking should appear in the June 15 results (BST date).
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)

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

	// 00:30 BST on June 15 = 23:30 UTC on June 14
	bkStart := time.Date(2026, 6, 15, 0, 30, 0, 0, londonLocation)
	bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkStart)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	defer fixtures.DeleteBooking(tx, bookingID)

	// Query available hours for June 15. The query uses londonLocation for
	// date boundaries, so it should see the 00:30 BST booking.
	response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
	targetDay := findDayByDate(response, "2026-06-15")
	if targetDay == nil {
		t.Fatal("expected day 2026-06-15 in response")
	}
	if !targetDay.IsOpen {
		t.Fatal("expected June 15 to be open")
	}

	// The booking at 00:30 BST should consume a slot before it.
	// If the date boundary was UTC-based, the booking would be invisible
	// (23:30 UTC June 14 < 00:00 UTC June 15 query start).
	// With londonLocation boundary, 00:30 BST is within the range.
	// Verify the 00:00 slot is NOT available (blocked by the 00:30 booking
	// because available-hours represents open slots, not individual bookings).
	// Actually, available-hours shows slots that ARE available, so a booking
	// at 00:30 means the 00:00 slot's 30-min window is partially taken.
	// For a 30-min service, 00:00 would be blocked by the 00:30 booking.
	if slotExists(targetDay.Slots, "00:00") {
		t.Error("expected 00:00 BST slot to be unavailable (booked at 00:30 BST)")
	}
}

// TestScheduling_DST_AutumnBack_BookingAt0130BST verifies that a booking at
// 01:30 BST (= 00:30 UTC) on Oct 25, 2026 (BST→GMT transition) is correctly
// handled. Oct 25 is the autumn DST date where clocks go back at 02:00 BST →
// 01:00 GMT, creating a duplicated 01:00-02:00 hour.
func TestScheduling_DST_AutumnBack_BookingAt0130BST(t *testing.T) {
	ctx, tx := resetTestData(t)

	// 2026-10-25 is the autumn DST date (BST→GMT). Sunday is closed
	// by default. Use exceptional hours override to open 09:00-17:00.
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Autumn DST BST Test', 'Test BST→GMT transition on 2026-10-25')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional group: %v", err)
	}
	tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, 6, '09:00:00', '17:00:00', true)
	`, groupID)
	tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, '2026-10-19')
	`, groupID)

	// Create a user and service for the booking
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)

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

	// Booking at 01:30 BST = 00:30 UTC on 2026-10-25 (spans DST transition)
	bookingTime := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
	var bookingID string
	err = tx.QueryRow(ctx, `
		INSERT INTO bookings (user_id, start_time, status, notes)
		VALUES ($1, $2, 'pending', 'Autumn DST BST booking at 01:30 BST')
		RETURNING id
	`, userID, bookingTime).Scan(&bookingID)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, `
		INSERT INTO booking_services (booking_id, service_id)
		VALUES ($1, $2)
	`, bookingID, serviceID)
	if err != nil {
		t.Fatalf("failed to link service to booking: %v", err)
	}

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
	targetDay := findDayByDate(response, "2026-10-25")
	if targetDay == nil {
		t.Fatal("expected day 2026-10-25 in response")
	}

	if !targetDay.IsOpen {
		t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
	}

	// 01:30 BST slot should be blocked (booking at 01:30 BST = 00:30 UTC)
	if slotExists(targetDay.Slots, "01:30") {
		t.Error("expected 01:30 BST slot to be blocked (booking at 01:30 BST = 00:30 UTC)")
	}
}

// TestScheduling_DST_AutumnBack_BookingAt0130GMT verifies that a booking at
// 01:30 GMT (= 01:30 UTC) on Oct 25, 2026 (BST→GMT transition) is also
// correctly handled. This is the second occurrence of 01:30 during the
// duplicated hour on the autumn DST day.
func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) {
	ctx, tx := resetTestData(t)

	// Same exceptional hours setup as the BST variant
	var groupID int
	err := tx.QueryRow(ctx, `
		INSERT INTO exceptional_working_hours_groups (name, description)
		VALUES ('Autumn DST GMT Test', 'Test BST→GMT transition on 2026-10-25')
		RETURNING id
	`).Scan(&groupID)
	if err != nil {
		t.Fatalf("failed to create exceptional group: %v", err)
	}
	tx.Exec(ctx, `
		INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
		VALUES ($1, 6, '09:00:00', '17:00:00', true)
	`, groupID)
	tx.Exec(ctx, `
		INSERT INTO exceptional_group_applications (group_id, week_start)
		VALUES ($1, '2026-10-19')
	`, groupID)

	// Create a user and service for the booking
	userID, err := fixtures.CreateTestUser(tx)
	if err != nil {
		t.Fatalf("failed to create user: %v", err)
	}
	defer fixtures.DeleteUser(tx, userID)

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

	// Booking at 01:30 GMT = 01:30 UTC on 2026-10-25
	bookingTime := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC)
	var bookingID string
	err = tx.QueryRow(ctx, `
		INSERT INTO bookings (user_id, start_time, status, notes)
		VALUES ($1, $2, 'pending', 'Autumn DST GMT booking at 01:30 GMT')
		RETURNING id
	`, userID, bookingTime).Scan(&bookingID)
	if err != nil {
		t.Fatalf("failed to create booking: %v", err)
	}
	_, err = tx.Exec(ctx, `
		INSERT INTO booking_services (booking_id, service_id)
		VALUES ($1, $2)
	`, bookingID, serviceID)
	if err != nil {
		t.Fatalf("failed to link service to booking: %v", err)
	}

	response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
	targetDay := findDayByDate(response, "2026-10-25")
	if targetDay == nil {
		t.Fatal("expected day 2026-10-25 in response")
	}

	if !targetDay.IsOpen {
		t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
	}

	// 01:30 GMT slot should be blocked (booking at 01:30 GMT = 01:30 UTC)
	if slotExists(targetDay.Slots, "01:30") {
		t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)")
	}
}

// TestScheduling_GetAvailableHours_ExcludesOwnReservation verifies that an
// authenticated user's own RESERVATION entry is excluded from time blockers
// via the excludeUserID parameter in GetTimeBlockersInRange.
func TestScheduling_GetAvailableHours_ExcludesOwnReservation(t *testing.T) {
	ctx, tx := resetTestData(t)

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

	reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
	_, 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)
	}

	// Request available hours as THIS user — their own reservation should be excluded
	handler := http.HandlerFunc(GetAvailableHours)
	req := httptest.NewRequest("GET", "/api/scheduling/available-hours?start=2026-03-17&end=2026-03-17", nil)
	reqCtx := context.WithValue(ctx, mw.UserIDKey, userID)
	reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "verified_email")
	req = req.WithContext(reqCtx)
	w := httptest.NewRecorder()
	handler.ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
	}
	var response []DayAvailableHours
	json.Unmarshal(w.Body.Bytes(), &response)
	targetDay := findDayByDate(response, "2026-03-17")
	if targetDay == nil {
		t.Fatal("expected 2026-03-17 in response")
	}

	// The user's own reservation at 10:00 should NOT block the slot
	if !slotExists(targetDay.Slots, "10:00") {
		t.Error("expected 10:00 to be available (own reservation excluded)")
	}
}
