package bookings

import (
	"context"
	"crussell/clock"
	"crussell/db"
	"crussell/handlers/notifications"
	"crussell/handlers/payments"
	"crussell/handlers/scheduling"
	"crussell/internal/dav"
	"crussell/internal/validators"
	"crussell/mw"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"log/slog"
	"math"
	"net/http"
	"sort"
	"strconv"
	"strings"

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

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

var londonLocation = func() *time.Location {
	loc, err := time.LoadLocation("Europe/London")
	if err != nil {
		panic("failed to load Europe/London timezone: " + err.Error())
	}
	return loc
}()

// Booking represents a booking in the system
type Booking struct {
	ID            string    `json:"id"`
	StartTime     time.Time `json:"start_time"`
	Status        string    `json:"status"`
	Notes         *string   `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	CreatedAt     time.Time `json:"created_at"`
	UpdatedAt     time.Time `json:"updated_at"`
	CreatedBy     *string   `json:"created_by,omitempty"`
	CreatedByName *string   `json:"created_by_name,omitempty"`
	OutOfHours    bool      `json:"out_of_hours"`

	// Deposit fields.
	// DepositRequired is snapshotted at creation from users.deposits_required > 0
	// and stored on the bookings row — so historic bookings reflect the obligation
	// that existed when they were made, not the user's current standing.
	DepositRequired        bool    `json:"deposit_required"`
	DepositAmount          float64 `json:"deposit_amount,omitempty"`
	DepositPaid            bool    `json:"deposit_paid"`
	DepositDeadline        *string `json:"deposit_deadline,omitempty"`
	DepositProtectedAmount float64 `json:"deposit_protected_amount,omitempty"`

	// Joined fields
	User            *UserSummary      `json:"user,omitempty"`
	Services        []BookingService  `json:"services"`
	Payments        []Payment         `json:"payments,omitempty"`
	Discounts       []BookingDiscount `json:"discounts,omitempty"`
	TotalAmount     float64           `json:"total_amount"`
	AmountPaid      float64           `json:"amount_paid"`
	AmountDue       float64           `json:"amount_due"`
	DurationMinutes int               `json:"duration_minutes"`
}

type BookingDiscount struct {
	ID              string    `json:"id"`
	BookingID       string    `json:"booking_id"`
	UserID          string    `json:"user_id"`
	DiscountSource  string    `json:"discount_source"`
	SourceID        *string   `json:"source_id,omitempty"`
	CampaignName    *string   `json:"campaign_name,omitempty"`
	CampaignType    *string   `json:"campaign_type,omitempty"`
	MilestoneType   *string   `json:"milestone_type,omitempty"`
	DiscountPercent float64   `json:"discount_percent"`
	OriginalTotal   float64   `json:"original_total"`
	DiscountAmount  float64   `json:"discount_amount"`
	AppliedAt       time.Time `json:"applied_at"`
}

func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDiscount, error) {
	discountRows, err := db.Conn.Query(ctx, `
		SELECT 
			bd.id, bd.booking_id, bd.user_id, bd.discount_source, bd.source_id,
			bd.campaign_type, bd.milestone_type, bd.discount_percent, bd.original_total, bd.discount_amount, bd.applied_at,
			c.name AS campaign_name
		FROM booking_discounts bd
		LEFT JOIN discount_campaigns c ON bd.discount_source = 'campaign' AND bd.source_id = c.id
		WHERE bd.booking_id = $1
		ORDER BY bd.applied_at ASC
	`, bookingID)
	if err != nil {
		return nil, err
	}
	defer discountRows.Close()

	var discounts []BookingDiscount
	for discountRows.Next() {
		var d BookingDiscount
		var campaignName sql.NullString
		var sourceID sql.NullString
		var campaignType sql.NullString
		var milestoneType sql.NullString
		if err := discountRows.Scan(
			&d.ID, &d.BookingID, &d.UserID, &d.DiscountSource, &sourceID,
			&campaignType, &milestoneType, &d.DiscountPercent, &d.OriginalTotal, &d.DiscountAmount, &d.AppliedAt,
			&campaignName,
		); err != nil {
			return nil, err
		}
		if sourceID.Valid {
			s := sourceID.String
			d.SourceID = &s
		}
		if campaignName.Valid && campaignName.String != "" {
			c := campaignName.String
			d.CampaignName = &c
		}
		if campaignType.Valid && campaignType.String != "" {
			c := campaignType.String
			d.CampaignType = &c
		}
		if milestoneType.Valid && milestoneType.String != "" {
			m := milestoneType.String
			d.MilestoneType = &m
		}
		discounts = append(discounts, d)
	}
	return discounts, nil
}

// populateDepositFields sets the computed deposit fields on a Booking.
// It must be called after TotalAmount, AmountPaid, and StartTime are already set.
//
//   - depositRequired: snapshotted value from bookings.deposit_required (set at creation).
//   - preStartAmountPaid: sum of completed payments whose created_at < booking.start_time.
func populateDepositFields(b *Booking, depositRequired bool, preStartAmountPaid float64) {
	b.DepositRequired = depositRequired
	b.DepositAmount = b.TotalAmount * payments.RequiredDepositPct
	b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount
	deadline := b.StartTime.Add(-payments.DepositDeadlineWindow).Format(time.RFC3339)
	b.DepositDeadline = &deadline

	paid := math.Max(0, preStartAmountPaid)
	maxProtected := b.TotalAmount * payments.ProtectedDepositMaxPct
	if paid < maxProtected {
		b.DepositProtectedAmount = paid
	} else {
		b.DepositProtectedAmount = maxProtected
	}
}

// BookingService represents a service associated with a booking
type BookingService struct {
	BookingID               string   `json:"booking_id"`
	ServiceID               string   `json:"service_id"`
	OverridePrice           *float64 `json:"override_price,omitempty"`
	OverrideDurationMinutes *int     `json:"override_duration_minutes,omitempty"`

	// Service details (joined)
	ServiceName        *string  `json:"service_name,omitempty"`
	ServiceDescription *string  `json:"service_description,omitempty"`
	Price              *float64 `json:"price,omitempty"`
	DurationMinutes    *int     `json:"duration_minutes,omitempty"`
}

// Payment represents a payment associated with a booking
type Payment struct {
	ID              string    `json:"id"`
	BookingID       string    `json:"booking_id"`
	PaymentType     string    `json:"payment_type"`
	PaymentMethod   string    `json:"payment_method"`
	VendorCode      *string   `json:"vendor_code,omitempty"`
	InvoiceNumber   *int      `json:"invoice_number,omitempty"`
	Status          string    `json:"status"`
	Amount          float64   `json:"amount"`
	IsVATApplicable bool      `json:"is_vat_applicable"`
	VATRate         *float64  `json:"vat_rate,omitempty"`
	VATAmount       *float64  `json:"vat_amount,omitempty"`
	NetAmount       *float64  `json:"net_amount,omitempty"`
	CreatedAt       time.Time `json:"created_at"`
	UpdatedAt       time.Time `json:"updated_at"`
	CreatedBy       *string   `json:"created_by,omitempty"`
}

// CreateBookingRequest represents the request payload for creating a new booking
type CreateBookingRequest struct {
	StartTime  time.Time `json:"start_time" validate:"required"`
	ServiceIDs []string  `json:"service_ids" validate:"required,min=1"`
	Notes      *string   `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	UserID     *string   `json:"user_id,omitempty"`
}

// EditBookingRequest represents the request payload for editing a booking's start time
type EditBookingRequest struct {
	StartTime time.Time `json:"start_time" validate:"required"`
}

// ProgressBookingRequest represents the request payload for updating a booking's status
type ProgressBookingRequest struct {
	Status string `json:"status" validate:"required,oneof=pending confirmed in_progress completed client_cancelled we_cancelled no_show"`
}

// ConfirmBookingRequest represents the request payload for confirming a booking
type ConfirmBookingRequest struct {
	ServiceOverrides       []ServiceOverride `json:"service_overrides,omitempty"`
	CustomServiceOverrides []ServiceOverride `json:"custom_service_overrides,omitempty"`
	Notes                  *string           `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}

// ServiceOverride represents override values for a specific service in a booking
type ServiceOverride struct {
	ServiceID               string   `json:"service_id" validate:"required"`
	OverridePrice           *float64 `json:"override_price,omitempty"`
	OverrideDurationMinutes *int     `json:"override_duration_minutes,omitempty"`
}

// UpdateBookingServicesRequest represents the request payload for admin updating a booking's services and notes
type UpdateBookingServicesRequest struct {
	ServiceIDs       []string          `json:"service_ids"`
	CustomServiceIDs []string          `json:"custom_service_ids,omitempty"`
	ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
	Notes            *string           `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}

// DeleteBookingRequest represents the request payload for deleting a booking with payment
type DeleteBookingRequest struct {
	Reason        string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled"`
	ForgiveNoShow *bool  `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time
}

// AdminUserSummary represents a small user summary for admin views
type AdminUserSummary struct {
	FullName      string  `json:"full_name"`
	ProfilePicURL *string `json:"profile_pic_url,omitempty"`
	Notes         *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
}

// AdminBookingSummary represents a complete booking summary for admin view
type AdminBookingSummary struct {
	Booking         Booking                `json:"booking"`
	User            *AdminUserSummary      `json:"user,omitempty"`
	Services        []BookingServiceDetail `json:"services"`
	Payments        []Payment              `json:"payments"`
	TotalAmount     float64                `json:"total_amount"`
	AmountPaid      float64                `json:"amount_paid"`
	AmountDue       float64                `json:"amount_due"`
	DurationMinutes int                    `json:"duration_minutes"`
}

type UserSummary struct {
	ID                string  `json:"id"`
	FirstName         string  `json:"first_name"`
	LastName          string  `json:"last_name"`
	FullName          string  `json:"full_name"`
	Email             *string `json:"email,omitempty"`
	Phone             *string `json:"phone,omitempty"`
	ProfilePicURL     *string `json:"profile_pic_url,omitempty"`
	DateOfBirth       *string `json:"date_of_birth,omitempty"`
	AccountRole       string  `json:"account_role"`
	LoyaltyStamps     *int    `json:"loyalty_stamps,omitempty"`
	ReferralCode      *string `json:"referral_code,omitempty"`
	ReferralCodeUses  *int    `json:"referral_code_uses,omitempty"`
	CreatedAt         string  `json:"created_at"`
	Notes             *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	PreviousFirstName *string `json:"previous_first_name,omitempty"`
	PreviousLastName  *string `json:"previous_last_name,omitempty"`
}

type BookingServiceDetail struct {
	ServiceName             string   `json:"service_name"`
	ServiceDescription      *string  `json:"service_description,omitempty"`
	BasePrice               float64  `json:"base_price"`
	BaseDurationMinutes     int      `json:"base_duration_minutes"`
	OverridePrice           *float64 `json:"override_price,omitempty"`
	OverrideDurationMinutes *int     `json:"override_duration_minutes,omitempty"`
	IsActive                bool     `json:"is_active"`
	RequiresPatchTest       bool     `json:"requires_patch_test"`
	MinimumAgeRequired      int      `json:"minimum_age_required"`
}

// GetAllBookingsRequest represents query parameters for getting all bookings
type GetAllBookingsRequest struct {
	Status    *string `json:"status,omitempty"`
	StartDate *string `json:"start_date,omitempty"`
	EndDate   *string `json:"end_date,omitempty"`
	Page      int     `json:"page"`
	PerPage   int     `json:"per_page"`
	Cursor    *string `json:"cursor,omitempty"`
}

// BookingListResponse represents a paginated list of bookings
type BookingListResponse struct {
	Bookings   []Booking `json:"bookings"`
	Total      int       `json:"total"`
	Page       int       `json:"page"`
	PerPage    int       `json:"perPage"`
	TotalPages int       `json:"totalPages"`
	NextCursor *string   `json:"next_cursor,omitempty"`
}

// SearchBookingsRequest represents search parameters
type SearchBookingsRequest struct {
	Query   string `json:"query"`
	Page    int    `json:"page"`
	PerPage int    `json:"per_page"`
}

// SearchBookingsResponse represents search results
type SearchBookingsResponse struct {
	Bookings []AdminBookingSummary `json:"bookings"`
	Page     int                   `json:"page"`
	PerPage  int                   `json:"per_page"`
	Total    int                   `json:"total"`
}

// Enhanced booking response for user endpoints
type UserBookingDetail struct {
	Booking         Booking `json:"booking"`
	TotalAmount     float64 `json:"total_amount"`
	AmountPaid      float64 `json:"amount_paid"`
	AmountDue       float64 `json:"amount_due"`
	DurationMinutes int     `json:"duration_minutes"`
}

// Enhanced booking response for admin endpoints
type AdminBookingDetail struct {
	Booking         Booking           `json:"booking"`
	User            *AdminUserSummary `json:"user,omitempty"`
	TotalAmount     float64           `json:"total_amount"`
	AmountPaid      float64           `json:"amount_paid"`
	AmountDue       float64           `json:"amount_due"`
	DurationMinutes int               `json:"duration_minutes"`
}

// roundTo2 rounds a float64 to 2 decimal places
func roundTo2(f float64) float64 {
	return float64(int(f*100+0.5)) / 100
}

// Helper function to parse query parameters
func parseGetAllBookingsRequest(r *http.Request) GetAllBookingsRequest {
	req := GetAllBookingsRequest{
		Page:    1,
		PerPage: 10,
	}
	if status := r.URL.Query().Get("status"); status != "" {
		req.Status = &status
	}
	if startDate := r.URL.Query().Get("start_date"); startDate != "" {
		req.StartDate = &startDate
	}
	if endDate := r.URL.Query().Get("end_date"); endDate != "" {
		req.EndDate = &endDate
	}
	// Accept both cursor (preferred) and page (deprecated) for backward compat
	if cursorStr := r.URL.Query().Get("cursor"); cursorStr != "" {
		req.Cursor = &cursorStr
	}
	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
		if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
			req.Page = page
		}
	}
	if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
		if perPage, err := strconv.Atoi(perPageStr); err == nil && perPage > 0 && perPage <= 500 {
			req.PerPage = perPage
		}
	}
	return req
}

// GET /api/bookings
func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || userID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	req := parseGetAllBookingsRequest(r)

	// Build WHERE conditions shared between count and data queries.
	whereClause := " WHERE b.user_id = $1"
	whereArgs := []any{userID}
	paramCount := 2

	if req.Status != nil {
		whereClause += fmt.Sprintf(" AND b.status = $%d", paramCount)
		whereArgs = append(whereArgs, *req.Status)
		paramCount++
	}
	if req.StartDate != nil {
		whereClause += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
		startTime, err := time.Parse("2006-01-02", *req.StartDate)
		if err != nil {
			http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		londonDate := startTime.In(londonLocation)
		startTime = time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC()
		whereArgs = append(whereArgs, startTime)
		paramCount++
	}
	if req.EndDate != nil {
		whereClause += fmt.Sprintf(" AND b.start_time <= $%d", paramCount)
		endTime, err := time.Parse("2006-01-02", *req.EndDate)
		if err != nil {
			http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		londonEnd := endTime.In(londonLocation)
		endTime = time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC()
		whereArgs = append(whereArgs, endTime)
		paramCount++
	}

	// Count query uses the same WHERE but without complex SELECT subqueries,
	// cursor, ORDER BY, or LIMIT — just a fast index scan on bookings.
	var total int
	if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+whereClause, whereArgs...).Scan(&total); err != nil {
		log.Printf("Failed to count bookings for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Data query with full SELECT but no COUNT(*) OVER() window function.
	dataQuery := `
        SELECT
            b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
            b.total_amount,
            COALESCE(pt.amount_paid, 0) AS amount_paid,
            b.total_duration_minutes AS duration_minutes,
            b.deposit_required,
            COALESCE(pt.pre_start_amount_paid, 0) AS pre_start_amount_paid
        FROM bookings b
        LEFT JOIN LATERAL (
            SELECT
                COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) AS amount_paid,
                COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < b.start_time), 0) AS pre_start_amount_paid
            FROM payments
            WHERE booking_id = b.id
        ) pt ON true` + whereClause

	dataArgs := make([]any, len(whereArgs))
	copy(dataArgs, whereArgs)
	dataParamCount := paramCount

	// Cursor-based pagination: (created_at, id)
	if req.Cursor != nil && *req.Cursor != "" {
		cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor)
		if err != nil {
			http.Error(w, "Invalid cursor", http.StatusBadRequest)
			return
		}
		dataQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", dataParamCount, dataParamCount+1)
		dataArgs = append(dataArgs, cursorCreatedAt, cursorID)
		dataParamCount += 2
	}

	dataQuery += " ORDER BY b.created_at DESC, b.id DESC"
	if req.PerPage > 0 {
		dataQuery += fmt.Sprintf(" LIMIT $%d", dataParamCount)
		dataArgs = append(dataArgs, req.PerPage+1)
	}

	rows, err := db.Conn.Query(r.Context(), dataQuery, dataArgs...)
	if err != nil {
		log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []Booking
	var bookingIDs []string

	for rows.Next() {
		var b Booking
		var createdBy sql.NullString
		var totalAmount, amountPaid, preStartAmountPaid float64
		var durationMinutes int
		var depositRequired bool

		if err := rows.Scan(
			&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &createdBy,
			&totalAmount, &amountPaid, &durationMinutes,
			&depositRequired, &preStartAmountPaid,
		); err != nil {
			log.Printf("Failed to scan booking row: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		b.DurationMinutes = durationMinutes
		if createdBy.Valid {
			b.CreatedBy = &createdBy.String
		}
		b.TotalAmount = totalAmount
		b.AmountPaid = amountPaid
		b.AmountDue = totalAmount - amountPaid
		b.DurationMinutes = durationMinutes
		populateDepositFields(&b, depositRequired, preStartAmountPaid)
		b.Services = []BookingService{}
		bookings = append(bookings, b)
		bookingIDs = append(bookingIDs, b.ID)
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT
				bs.service_id, bs.override_price, bs.override_duration_minutes,
				s.name, s.description, s.price, s.duration_minutes,
				bs.booking_id
			FROM booking_services bs
			LEFT JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = ANY($1)
			UNION ALL
			SELECT
				bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
				cs.name, cs.description, cs.price, cs.duration_minutes,
				bcs.booking_id
			FROM booking_custom_services bcs
			LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
			WHERE bcs.booking_id = ANY($1)
			ORDER BY booking_id, name
		`, bookingIDs)
		if err != nil {
			log.Printf("Failed to fetch booking services: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		defer serviceRows.Close()

		servicesByBooking := make(map[string][]BookingService)
		for serviceRows.Next() {
			var s BookingService
			var overridePrice sql.NullFloat64
			var overrideDuration sql.NullInt32
			var name, description sql.NullString
			var basePrice sql.NullFloat64
			var baseDuration sql.NullInt32
			var bookingID string

			if err := serviceRows.Scan(
				&s.ServiceID,
				&overridePrice, &overrideDuration,
				&name, &description, &basePrice, &baseDuration,
				&bookingID,
			); err != nil {
				log.Printf("Failed to scan service row: %v", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}

			s.BookingID = bookingID

			if overridePrice.Valid {
				s.OverridePrice = &overridePrice.Float64
			}
			if overrideDuration.Valid {
				d := int(overrideDuration.Int32)
				s.OverrideDurationMinutes = &d
			}
			if name.Valid {
				s.ServiceName = &name.String
			}
			if description.Valid {
				s.ServiceDescription = &description.String
			}
			if basePrice.Valid {
				s.Price = &basePrice.Float64
			}
			if baseDuration.Valid {
				d := int(baseDuration.Int32)
				s.DurationMinutes = &d
			}

			servicesByBooking[bookingID] = append(servicesByBooking[bookingID], s)
		}
		for i := range bookings {
			if services, exists := servicesByBooking[bookings[i].ID]; exists {
				bookings[i].Services = services
			}
		}
	}

	// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
	// The extra item is discarded; the cursor points to the last real item.
	var nextCursor *string
	if len(bookings) > req.PerPage {
		bookings = bookings[:req.PerPage]
		last := bookings[len(bookings)-1]
		cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
		nextCursor = &cursor
	}

	totalPages := (total + req.PerPage - 1) / req.PerPage
	if req.PerPage <= 0 {
		totalPages = 1
	}
	if totalPages == 0 {
		totalPages = 1
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings:   bookings,
		Page:       req.Page,
		PerPage:    req.PerPage,
		Total:      total,
		TotalPages: totalPages,
		NextCursor: nextCursor,
	}); err != nil {
		log.Printf("Failed to encode response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings
func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
	req := parseGetAllBookingsRequest(r)

	baseQuery := `
        WITH booking_totals AS (
            SELECT
                booking_id,
                COUNT(*) AS service_count,
                SUM(total_duration) AS total_duration,
                SUM(total_amount) AS total_amount
            FROM (
                SELECT
                    bs.booking_id,
                    COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
                    COALESCE(bs.override_price, s.price) AS total_amount
                FROM booking_services bs
                LEFT JOIN services s ON bs.service_id = s.id
                UNION ALL
                SELECT
                    bcs.booking_id,
                    COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
                    COALESCE(bcs.override_price, cs.price) AS total_amount
                FROM booking_custom_services bcs
                LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
            ) combined
            GROUP BY booking_id
        ),
        payment_totals AS (
            SELECT
                booking_id,
                SUM(amount) AS total_paid
            FROM payments
            WHERE status = 'completed'
            GROUP BY booking_id
        )
		SELECT
            b.id,
            b.created_at,
            b.user_id,
            b.start_time,
            b.status,
            u.fn,
            COALESCE(bt.total_duration, 0) AS duration_minutes,
            COALESCE(bt.total_amount, 0) AS total_amount,
            COALESCE(pt.total_paid, 0) AS amount_paid,
            COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
            b.deposit_required,
            COALESCE(bt.service_count, 0) AS service_count,
            COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid,
            b.out_of_hours
         FROM bookings b
        LEFT JOIN users u ON b.user_id = u.id
        LEFT JOIN booking_totals bt ON b.id = bt.booking_id
        LEFT JOIN payment_totals pt ON b.id = pt.booking_id
        LEFT JOIN LATERAL (
            SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
            FROM payments
            WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
        ) pre_pay ON true
    `

	var args []any
	paramCount := 1

	addWhereClause := func(condition string) {
		if paramCount == 1 {
			baseQuery += " WHERE " + condition
		} else {
			baseQuery += " AND " + condition
		}
	}

	if req.Status != nil {
		addWhereClause(fmt.Sprintf("b.status = $%d", paramCount))
		args = append(args, *req.Status)
		paramCount++
	}
	if req.StartDate != nil {
		addWhereClause(fmt.Sprintf("b.start_time >= $%d", paramCount))
		startTime, err := time.Parse("2006-01-02", *req.StartDate)
		if err != nil {
			http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		londonDate := startTime.In(londonLocation)
		args = append(args, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
		paramCount++
	}
	if req.EndDate != nil {
		addWhereClause(fmt.Sprintf("b.start_time <= $%d", paramCount))
		endTime, err := time.Parse("2006-01-02", *req.EndDate)
		if err != nil {
			http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		londonEnd := endTime.In(londonLocation)
		args = append(args, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
		paramCount++
	}

	// Cursor-based pagination: (created_at, id)
	if req.Cursor != nil && *req.Cursor != "" {
		cursorCreatedAt, cursorID, err := validators.ParseCursor(*req.Cursor)
		if err != nil {
			http.Error(w, "Invalid cursor", http.StatusBadRequest)
			return
		}
		if paramCount == 1 {
			baseQuery += " WHERE (b.start_time, b.id) < ($1, $2)"
		} else {
			baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
		}
		args = append(args, cursorCreatedAt, cursorID)
		paramCount += 2
	}

	baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
	if req.PerPage > 0 {
		baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
		args = append(args, req.PerPage+1)
	}

	// Count query: simple SELECT COUNT(*) with same WHERE (no CTEs, joins, or subqueries).
	// Run BEFORE the data query to avoid "conn busy" errors when routing
	// through a per-test transaction (pgx.Tx does not support concurrent queries).
	var total int
	{
		countWhere := ""
		var countArgs []any
		cp := 1
		addCountWhere := func(cond string) {
			if cp == 1 {
				countWhere = " WHERE " + cond
			} else {
				countWhere += " AND " + cond
			}
			cp++
		}
		if req.Status != nil {
			addCountWhere(fmt.Sprintf("b.status = $%d", cp))
			countArgs = append(countArgs, *req.Status)
		}
		if req.StartDate != nil {
			addCountWhere(fmt.Sprintf("b.start_time >= $%d", cp))
			st, _ := time.Parse("2006-01-02", *req.StartDate)
			londonDate := st.In(londonLocation)
			countArgs = append(countArgs, time.Date(londonDate.Year(), londonDate.Month(), londonDate.Day(), 0, 0, 0, 0, londonLocation).UTC())
		}
		if req.EndDate != nil {
			addCountWhere(fmt.Sprintf("b.start_time <= $%d", cp))
			et, _ := time.Parse("2006-01-02", *req.EndDate)
			londonEnd := et.In(londonLocation)
			countArgs = append(countArgs, time.Date(londonEnd.Year(), londonEnd.Month(), londonEnd.Day(), 23, 59, 59, 999999999, londonLocation).UTC())
		}
		if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
			log.Printf("Failed to count admin bookings: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
	if err != nil {
		log.Printf("Failed to fetch all bookings: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []Booking
	var bookingIDs []string

	for rows.Next() {
		var b Booking
		var userFullName string
		var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
		var depositRequired bool
		var createdAt time.Time
		var bookingUserID string

		if err := rows.Scan(
			&b.ID, &createdAt, &bookingUserID, &b.StartTime, &b.Status, &userFullName,
			&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
			&depositRequired, new(int), &preStartAmountPaid,
			&b.OutOfHours,
		); err != nil {
			log.Printf("Failed to scan booking row: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		b.User = &UserSummary{ID: bookingUserID, FullName: userFullName}
		b.CreatedAt = createdAt
		b.TotalAmount = totalAmount
		b.AmountPaid = amountPaid
		b.AmountDue = amountDue
		populateDepositFields(&b, depositRequired, preStartAmountPaid)
		bookings = append(bookings, b)
		bookingIDs = append(bookingIDs, b.ID)
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT bs.booking_id, s.name
			FROM booking_services bs
			JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = ANY($1)
			UNION ALL
			SELECT bcs.booking_id, cs.name
			FROM booking_custom_services bcs
			JOIN custom_services cs ON bcs.custom_service_id = cs.id
			WHERE bcs.booking_id = ANY($1)
			ORDER BY booking_id, name
		`, bookingIDs)
		if err != nil {
			log.Printf("Failed to fetch booking services: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		defer serviceRows.Close()

		servicesByBooking := make(map[string][]BookingService)
		for serviceRows.Next() {
			var bookingID, serviceName string
			if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
				log.Printf("Failed to scan service row: %v", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			n := serviceName
			servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
				BookingID:   bookingID,
				ServiceName: &n,
			})
		}
		for i := range bookings {
			if services, exists := servicesByBooking[bookings[i].ID]; exists {
				bookings[i].Services = services
			}
		}
	}

	{
		seenUserIDs := make(map[string]struct{})
		var userIDs []string
		for _, b := range bookings {
			if b.User != nil && b.User.ID != "" {
				if _, seen := seenUserIDs[b.User.ID]; !seen {
					seenUserIDs[b.User.ID] = struct{}{}
					userIDs = append(userIDs, b.User.ID)
				}
			}
		}
		if len(userIDs) > 0 {
			nhRows, err := db.Conn.Query(r.Context(), `
				SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
				FROM name_history
				WHERE user_id = ANY($1) AND booking_id IS NULL
				ORDER BY user_id, changed_at ASC
			`, userIDs)
			if err == nil {
				prevByUser := make(map[string][2]string)
				for nhRows.Next() {
					var uid, pfn, pln string
					if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
						prevByUser[uid] = [2]string{pfn, pln}
					}
				}
				nhRows.Close()
				for i := range bookings {
					if bookings[i].User != nil {
						if prev, ok := prevByUser[bookings[i].User.ID]; ok {
							bookings[i].User.PreviousFirstName = &prev[0]
							bookings[i].User.PreviousLastName = &prev[1]
						}
					}
				}
			}
		}
	}

	// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
	// The extra item is discarded; the cursor points to the last real item.
	var nextCursor *string
	if len(bookings) > req.PerPage {
		bookings = bookings[:req.PerPage]
		last := bookings[len(bookings)-1]
		cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
		nextCursor = &cursor
	}

	totalPages := (total + req.PerPage - 1) / req.PerPage
	if totalPages == 0 {
		totalPages = 1
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings:   bookings,
		Page:       req.Page,
		PerPage:    req.PerPage,
		Total:      total,
		TotalPages: totalPages,
		NextCursor: nextCursor,
	}); err != nil {
		log.Printf("Failed to encode response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings/user/{user_id}
func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
	userID := chi.URLParam(r, "user_id")
	if userID == "" || !validators.IsValidID(userID) {
		http.Error(w, "user not found", http.StatusNotFound)
		return
	}

	query := r.URL.Query()
	perPage := 5
	cursorStr := query.Get("cursor")
	if perPageStr := query.Get("per_page"); perPageStr != "" {
		if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
			perPage = pp
		}
	}

	baseQuery := `
		SELECT b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
		       b.deposit_required
		FROM bookings b
		WHERE b.user_id = $1
	`
	var args []any
	args = append(args, userID)
	paramCount := 2

	if cursorStr != "" {
		cursorStartTime, cursorID, err := validators.ParseCursor(cursorStr)
		if err != nil {
			http.Error(w, "Invalid cursor", http.StatusBadRequest)
			return
		}
		baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
		args = append(args, cursorStartTime, cursorID)
		paramCount += 2
	}

	baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
	baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
	args = append(args, perPage+1)

	// Count query: simple SELECT COUNT(*) (no cursor, since that filters rows).
	var total int
	if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b WHERE b.user_id = $1", userID).Scan(&total); err != nil {
		log.Printf("Failed to count bookings for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
	if err != nil {
		log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []Booking
	var bookingIDs []string

	for rows.Next() {
		var b Booking
		var depositRequired bool
		if err := rows.Scan(
			&b.ID, &b.StartTime, &b.Status, &b.Notes,
			&b.CreatedAt, &b.UpdatedAt, &b.CreatedBy,
			&depositRequired,
		); err != nil {
			log.Printf("Failed to scan booking row: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		b.DepositRequired = depositRequired
		bookings = append(bookings, b)
		bookingIDs = append(bookingIDs, b.ID)
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT
				bs.booking_id,
				s.name,
				COALESCE(bs.override_price, s.price) AS price,
				COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
			FROM booking_services bs
			LEFT JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = ANY($1)
			UNION ALL
			SELECT
				bcs.booking_id,
				cs.name,
				COALESCE(bcs.override_price, cs.price) AS price,
				COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
			FROM booking_custom_services bcs
			LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
			WHERE bcs.booking_id = ANY($1)
		`, bookingIDs)
		if err != nil {
			log.Printf("Failed to fetch services: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		servicesByBooking := make(map[string][]BookingService)
		serviceTotalByBooking := make(map[string]float64)
		for serviceRows.Next() {
			var bookingID string
			var svc BookingService
			var price float64
			var dur int
			if err := serviceRows.Scan(&bookingID, &svc.ServiceName, &price, &dur); err != nil {
				log.Printf("Failed to scan service: %v", err)
				continue
			}
			svc.Price = &price
			svc.DurationMinutes = &dur
			servicesByBooking[bookingID] = append(servicesByBooking[bookingID], svc)
			serviceTotalByBooking[bookingID] += price
		}
		serviceRows.Close()

		paymentRows, err := db.Conn.Query(r.Context(), `
			SELECT p.booking_id,
			       COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid,
			       COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid
			FROM payments p
			JOIN bookings b ON b.id = p.booking_id
			WHERE p.booking_id = ANY($1)
			GROUP BY p.booking_id, b.start_time
		`, bookingIDs)
		if err != nil {
			log.Printf("Failed to fetch payments: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		paymentsByBooking := make(map[string]struct{ amountPaid, preStartPaid float64 })
		for paymentRows.Next() {
			var bookingID string
			var amountPaid, preStartPaid float64
			if err := paymentRows.Scan(&bookingID, &amountPaid, &preStartPaid); err != nil {
				log.Printf("Failed to scan payment: %v", err)
				continue
			}
			paymentsByBooking[bookingID] = struct{ amountPaid, preStartPaid float64 }{amountPaid, preStartPaid}
		}
		paymentRows.Close()

		for i := range bookings {
			bid := bookings[i].ID
			if svcs, ok := servicesByBooking[bid]; ok {
				bookings[i].Services = svcs
				bookings[i].TotalAmount = serviceTotalByBooking[bid]
			} else {
				bookings[i].Services = []BookingService{}
			}

			if p, ok := paymentsByBooking[bid]; ok {
				bookings[i].AmountPaid = p.amountPaid
				bookings[i].AmountDue = bookings[i].TotalAmount - p.amountPaid
				populateDepositFields(&bookings[i], bookings[i].DepositRequired, p.preStartPaid)
			} else {
				bookings[i].AmountDue = bookings[i].TotalAmount
				populateDepositFields(&bookings[i], bookings[i].DepositRequired, 0)
			}
		}
	}

	if bookings == nil {
		bookings = []Booking{}
	}

	// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
	// The extra item is discarded; the cursor points to the last real item.
	var nextCursor *string
	if len(bookings) > perPage {
		bookings = bookings[:perPage]
		last := bookings[len(bookings)-1]
		cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
		nextCursor = &cursor
	}

	totalPages := (total + perPage - 1) / perPage
	if totalPages == 0 {
		totalPages = 1
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings:   bookings,
		Total:      total,
		PerPage:    perPage,
		TotalPages: totalPages,
		NextCursor: nextCursor,
	}); err != nil {
		log.Printf("Failed to encode bookings response: %v", err)
	}
}

// GET /api/admin/bookings/{id}
func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	var depositRequired bool
	var dateOfBirth sql.NullTime

	err := db.Conn.QueryRow(r.Context(), `
		SELECT
			b.id, b.user_id, b.start_time, b.status, b.notes,
			b.created_at, b.updated_at, b.created_by,
			b.out_of_hours,
			u.fn, u.email, u.phone, u.profile_pic_url, u.date_of_birth, u.loyalty_stamps,
			u.referral_code, u.notes,
			creator.fn,
			b.deposit_required
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		LEFT JOIN users creator ON b.created_by = creator.id
		WHERE b.id = $1
	`, bookingID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
		&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
		&booking.OutOfHours,
		&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
		&booking.User.ProfilePicURL, &dateOfBirth, &booking.User.LoyaltyStamps,
		&booking.User.ReferralCode, &booking.User.Notes,
		&booking.CreatedByName,
		&depositRequired,
	)
	if dateOfBirth.Valid {
		s := dateOfBirth.Time.Format("2006-01-02")
		booking.User.DateOfBirth = &s
	}
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var referralCodeUses int
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
	`, booking.User.ID).Scan(&referralCodeUses); err != nil {
		log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
	}
	booking.User.ReferralCodeUses = &referralCodeUses

	var prevFirstName, prevLastName sql.NullString
	err = db.Conn.QueryRow(r.Context(), `
		SELECT nh.previous_first_name, nh.previous_last_name
		FROM name_history nh
		WHERE nh.user_id = $1 AND nh.booking_id IS NULL
		ORDER BY nh.changed_at ASC
		LIMIT 1
	`, booking.User.ID).Scan(&prevFirstName, &prevLastName)
	if err == nil && prevFirstName.Valid && prevLastName.Valid {
		booking.User.PreviousFirstName = &prevFirstName.String
		booking.User.PreviousLastName = &prevLastName.String
	}

	serviceRows, err := db.Conn.Query(r.Context(), `
		SELECT service_id, name, price, duration_minutes FROM (
			SELECT
				bs.service_id, s.name,
				COALESCE(bs.override_price, s.price) AS price,
				COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
			FROM booking_services bs
			LEFT JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = $1
			UNION ALL
			SELECT
				bcs.custom_service_id, cs.name,
				COALESCE(bcs.override_price, cs.price) AS price,
				COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS duration_minutes
			FROM booking_custom_services bcs
			LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
			WHERE bcs.booking_id = $1
		) sub ORDER BY name
	`, bookingID)
	if err != nil {
		log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer serviceRows.Close()

	var totalAmount float64
	for serviceRows.Next() {
		var serviceID, name string
		var price float64
		var dur int
		if err := serviceRows.Scan(&serviceID, &name, &price, &dur); err != nil {
			log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		totalAmount += price
		booking.DurationMinutes += dur
		n, p, d := name, price, dur
		booking.Services = append(booking.Services, BookingService{
			ServiceID:       serviceID,
			ServiceName:     &n,
			Price:           &p,
			DurationMinutes: &d,
		})
	}
	booking.TotalAmount = totalAmount

	paymentRows, err := db.Conn.Query(r.Context(), `
		SELECT payment_type, payment_method, vendor_code, invoice_number,
		       status, amount, created_at
		FROM payments
		WHERE booking_id = $1
		ORDER BY created_at ASC
	`, bookingID)
	if err != nil {
		log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer paymentRows.Close()

	var amountPaid, preStartAmountPaid float64
	for paymentRows.Next() {
		var p Payment
		var vendorCode sql.NullString
		var invoiceNumber sql.NullInt32
		if err := paymentRows.Scan(
			&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
			&p.Status, &p.Amount, &p.CreatedAt,
		); err != nil {
			log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if vendorCode.Valid && vendorCode.String != "" {
			p.VendorCode = &vendorCode.String
		}
		if invoiceNumber.Valid {
			num := int(invoiceNumber.Int32)
			p.InvoiceNumber = &num
		}
		booking.Payments = append(booking.Payments, p)
		if p.Status == "completed" {
			amountPaid += p.Amount
			if p.CreatedAt.Before(booking.StartTime) {
				preStartAmountPaid += p.Amount
			}
		}
	}

	booking.AmountPaid = amountPaid
	booking.AmountDue = totalAmount - amountPaid
	populateDepositFields(&booking, depositRequired, preStartAmountPaid)

	discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
	if err != nil {
		log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	booking.Discounts = discounts

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// PUT /api/admin/bookings/{id}/services
func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	adminID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || adminID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	var req UpdateBookingServicesRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}
	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if len(req.ServiceIDs) == 0 {
		http.Error(w, "At least one service is required", http.StatusBadRequest)
		return
	}

	for _, sid := range req.ServiceIDs {
		if !validators.IsValidID(sid) {
			http.Error(w, "Invalid service ID", http.StatusBadRequest)
			return
		}
	}

	for _, override := range req.ServiceOverrides {
		if override.OverridePrice != nil && *override.OverridePrice < 0 {
			http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
			return
		}
		if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
			http.Error(w, "Override duration must be positive", http.StatusBadRequest)
			return
		}
	}

	var startTime time.Time
	var currentStatus string
	if err := db.Conn.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	rejectedStatuses := map[string]bool{
		"completed":        true,
		"client_cancelled": true,
		"we_cancelled":     true,
		"no_show":          true,
	}
	if rejectedStatuses[currentStatus] {
		http.Error(w, "Cannot update services on a completed, cancelled, or no-show booking", http.StatusForbidden)
		return
	}

	overrideMap := make(map[string]*ServiceOverride)
	for i := range req.ServiceOverrides {
		overrideMap[req.ServiceOverrides[i].ServiceID] = &req.ServiceOverrides[i]
	}

	// Batch load service durations
	serviceDurationMap := make(map[string]int)
	var serviceIDsNeedingLookup []string
	for _, serviceID := range req.ServiceIDs {
		if ov, exists := overrideMap[serviceID]; !(exists && ov.OverrideDurationMinutes != nil) {
			serviceIDsNeedingLookup = append(serviceIDsNeedingLookup, serviceID)
		}
	}
	if len(serviceIDsNeedingLookup) > 0 {
		rows, err := db.Conn.Query(r.Context(), `SELECT id, duration_minutes FROM services WHERE id = ANY($1)`, serviceIDsNeedingLookup)
		if err != nil {
			log.Printf("Failed to batch fetch service durations: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		defer rows.Close()
		for rows.Next() {
			var svcID string
			var dur int
			if err := rows.Scan(&svcID, &dur); err != nil {
				log.Printf("Failed to scan service duration row: %v", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			serviceDurationMap[svcID] = dur
		}
		if err := rows.Err(); err != nil {
			log.Printf("Failed to iterate service duration rows: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	var newTotalDuration int
	for _, serviceID := range req.ServiceIDs {
		var durationMinutes int
		if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil {
			durationMinutes = *ov.OverrideDurationMinutes
		} else {
			var ok bool
			durationMinutes, ok = serviceDurationMap[serviceID]
			if !ok {
				http.Error(w, "Service not found", http.StatusBadRequest)
				return
			}
		}
		newTotalDuration += durationMinutes
	}

	newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)

	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		log.Printf("Failed to start transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	// Evict any pending_release bookings that overlap this slot.
	if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, startTime, newEndTime); evictErr != nil {
		log.Printf("Failed to evict pending_release bookings for slot %s: %v", startTime, evictErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var overlapCount int
	if err := tx.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings
		WHERE id != $1
		AND status IN ('confirmed', 'pending', 'in_progress', 'completed')
		AND start_time < $3
		AND end_time > $2
	`, bookingID, startTime, newEndTime).Scan(&overlapCount); err != nil {
		log.Printf("Failed to check overlap: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if overlapCount > 0 {
		http.Error(w, "New booking duration overlaps with an existing booking", http.StatusConflict)
		return
	}

	if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
		log.Printf("Failed to delete booking services for %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if _, err := tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID); err != nil {
		log.Printf("Failed to delete booking custom services for %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	for _, serviceID := range req.ServiceIDs {
		var ovPrice *float64
		var ovDuration *int
		if ov, exists := overrideMap[serviceID]; exists {
			ovPrice = ov.OverridePrice
			ovDuration = ov.OverrideDurationMinutes
		}
		if _, err := tx.Exec(r.Context(), `
			INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes)
			VALUES ($1, $2, $3, $4)
		`, bookingID, serviceID, ovPrice, ovDuration); err != nil {
			log.Printf("Failed to insert booking service %s for booking %s: %v", serviceID, bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	for _, csID := range req.CustomServiceIDs {
		if _, err := tx.Exec(r.Context(), `
			INSERT INTO booking_custom_services (booking_id, custom_service_id)
			VALUES ($1, $2)
		`, bookingID, csID); err != nil {
			log.Printf("Failed to insert custom booking service %s: %v", csID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if req.Notes != nil {
		if _, err := tx.Exec(r.Context(), "UPDATE bookings SET notes = $1 WHERE id = $2", *req.Notes, bookingID); err != nil {
			log.Printf("Failed to update notes for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if err := tx.Commit(r.Context()); err != nil {
		log.Printf("Failed to commit transaction for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	var depositRequired bool

	err = db.Conn.QueryRow(r.Context(), `
		SELECT
			b.id, b.user_id, b.start_time, b.status, b.notes,
			b.created_at, b.updated_at, b.created_by,
			u.fn, u.email, u.phone, u.profile_pic_url, u.loyalty_stamps,
			u.referral_code, u.notes,
			b.deposit_required
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.id = $1
	`, bookingID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status, &booking.Notes,
		&booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
		&booking.User.FullName, &booking.User.Email, &booking.User.Phone,
		&booking.User.ProfilePicURL, &booking.User.LoyaltyStamps,
		&booking.User.ReferralCode, &booking.User.Notes,
		&depositRequired,
	)
	if err != nil {
		log.Printf("Failed to fetch updated booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var referralCodeUses int
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
	`, booking.User.ID).Scan(&referralCodeUses); err != nil {
		log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
	}
	booking.User.ReferralCodeUses = &referralCodeUses

	serviceRows, err := db.Conn.Query(r.Context(), `
		SELECT
			bs.service_id, bs.override_price, bs.override_duration_minutes,
			s.name, s.description, s.price, s.duration_minutes
		FROM booking_services bs
		LEFT JOIN services s ON bs.service_id = s.id
		WHERE bs.booking_id = $1
		UNION ALL
		SELECT
			bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
			cs.name, cs.description, cs.price, cs.duration_minutes
		FROM booking_custom_services bcs
		LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
		WHERE bcs.booking_id = $1
		ORDER BY name
	`, bookingID)
	if err != nil {
		log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer serviceRows.Close()

	var totalAmount float64
	for serviceRows.Next() {
		var serviceID string
		var overridePrice sql.NullFloat64
		var overrideDuration sql.NullInt32
		var name, description sql.NullString
		var basePrice sql.NullFloat64
		var baseDuration sql.NullInt32

		if err := serviceRows.Scan(&serviceID, &overridePrice, &overrideDuration, &name, &description, &basePrice, &baseDuration); err != nil {
			log.Printf("Failed to scan service for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		var priceToAdd float64
		var durationToAdd int
		var bs BookingService
		bs.ServiceID = serviceID

		if overridePrice.Valid {
			bs.OverridePrice = &overridePrice.Float64
			priceToAdd = overridePrice.Float64
		} else if basePrice.Valid {
			priceToAdd = basePrice.Float64
			p := basePrice.Float64
			bs.Price = &p
		}
		if overrideDuration.Valid {
			d := int(overrideDuration.Int32)
			bs.OverrideDurationMinutes = &d
			durationToAdd = d
		} else if baseDuration.Valid {
			durationToAdd = int(baseDuration.Int32)
			d := int(baseDuration.Int32)
			bs.DurationMinutes = &d
		}

		totalAmount += priceToAdd
		booking.DurationMinutes += durationToAdd

		if name.Valid {
			n := name.String
			bs.ServiceName = &n
		}
		if description.Valid {
			bs.ServiceDescription = &description.String
		}
		booking.Services = append(booking.Services, bs)
	}
	booking.TotalAmount = totalAmount

	paymentRows, err := db.Conn.Query(r.Context(), `
		SELECT payment_type, payment_method, vendor_code, invoice_number,
		       status, amount, created_at
		FROM payments
		WHERE booking_id = $1
		ORDER BY created_at ASC
	`, bookingID)
	if err != nil {
		log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer paymentRows.Close()

	var amountPaid, preStartAmountPaid float64
	for paymentRows.Next() {
		var p Payment
		var vendorCode sql.NullString
		var invoiceNumber sql.NullInt32
		if err := paymentRows.Scan(
			&p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
			&p.Status, &p.Amount, &p.CreatedAt,
		); err != nil {
			log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if vendorCode.Valid && vendorCode.String != "" {
			p.VendorCode = &vendorCode.String
		}
		if invoiceNumber.Valid {
			num := int(invoiceNumber.Int32)
			p.InvoiceNumber = &num
		}
		booking.Payments = append(booking.Payments, p)
		if p.Status == "completed" {
			amountPaid += p.Amount
			if p.CreatedAt.Before(booking.StartTime) {
				preStartAmountPaid += p.Amount
			}
		}
	}

	booking.AmountPaid = amountPaid
	booking.AmountDue = totalAmount - amountPaid
	populateDepositFields(&booking, depositRequired, preStartAmountPaid)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings/search
func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query().Get("q")
	if query == "" {
		http.Error(w, "Search query 'q' is required", http.StatusBadRequest)
		return
	}
	if len(query) > 200 {
		http.Error(w, "search query too long", http.StatusBadRequest)
		return
	}

	perPage := 10
	cursorStr := r.URL.Query().Get("cursor")
	if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
		if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
			perPage = pp
		}
	}

	escapedQuery := strings.ReplaceAll(query, `\`, `\\`)
	escapedQuery = strings.ReplaceAll(escapedQuery, `%`, `\%`)
	escapedQuery = strings.ReplaceAll(escapedQuery, `_`, `\_`)
	searchPattern := "%" + escapedQuery + "%"

	searchQuery := `
        WITH booking_totals AS (
            SELECT
                booking_id,
                SUM(total_duration) AS total_duration,
                SUM(total_amount) AS total_amount
            FROM (
                SELECT
                    bs.booking_id,
                    COALESCE(bs.override_duration_minutes, s.duration_minutes) AS total_duration,
                    COALESCE(bs.override_price, s.price) AS total_amount
                FROM booking_services bs
                LEFT JOIN services s ON bs.service_id = s.id
                UNION ALL
                SELECT
                    bcs.booking_id,
                    COALESCE(bcs.override_duration_minutes, cs.duration_minutes) AS total_duration,
                    COALESCE(bcs.override_price, cs.price) AS total_amount
                FROM booking_custom_services bcs
                LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
            ) combined
            GROUP BY booking_id
        ),
        payment_totals AS (
            SELECT
                booking_id,
                SUM(amount) AS total_paid
            FROM payments
            WHERE status = 'completed'
            GROUP BY booking_id
        )
        SELECT
            b.id,
            b.start_time,
            b.status,
            u.fn AS full_name,
            COALESCE(bt.total_duration, 0) AS duration_minutes,
            COALESCE(bt.total_amount, 0) AS total_amount,
            COALESCE(pt.total_paid, 0) AS amount_paid,
            COALESCE(bt.total_amount, 0) - COALESCE(pt.total_paid, 0) AS amount_due,
            b.deposit_required,
            COALESCE(pre_pay.pre_start_amount_paid, 0) AS pre_start_amount_paid,
            b.out_of_hours
         FROM bookings b
        LEFT JOIN users u ON b.user_id = u.id
        LEFT JOIN booking_totals bt ON b.id = bt.booking_id
        LEFT JOIN payment_totals pt ON b.id = pt.booking_id
        LEFT JOIN LATERAL (
            SELECT COALESCE(SUM(amount), 0) AS pre_start_amount_paid
            FROM payments
            WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time
        ) pre_pay ON true
        -- NOTE: ILIKE with leading wildcard prevents B-tree index usage.
        -- At scale, replace with pg_trgm GIN index: CREATE INDEX idx_bookings_search_trgm ON bookings USING GIN (id gin_trgm_ops, notes gin_trgm_ops);
        -- Also consider indexes on users (n_first_name, n_last_name, fn, email, phone) for the joined table.
        WHERE
            b.id ILIKE $1 ESCAPE '\' OR
            b.notes ILIKE $1 ESCAPE '\' OR
            b.status::text ILIKE $1 ESCAPE '\' OR
            u.n_first_name ILIKE $1 ESCAPE '\' OR
            u.n_last_name ILIKE $1 ESCAPE '\' OR
            u.fn ILIKE $1 ESCAPE '\' OR
            u.email ILIKE $1 ESCAPE '\' OR
            u.phone ILIKE $1 ESCAPE '\' OR
            EXISTS (
                SELECT 1 FROM booking_services bs
                JOIN services s ON bs.service_id = s.id
                WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\'
            ) OR
            EXISTS (
                SELECT 1 FROM booking_custom_services bcs
                JOIN custom_services cs ON bcs.custom_service_id = cs.id
                WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\'
            )
    `
	var args []any
	args = append(args, searchPattern)
	paramCount := 2

	// Cursor-based pagination: (created_at, id)
	if cursorStr != "" {
		cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
		if err != nil {
			http.Error(w, "Invalid cursor", http.StatusBadRequest)
			return
		}
		searchQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1)
		args = append(args, cursorCreatedAt, cursorID)
		paramCount += 2
	}

	searchQuery += " ORDER BY b.created_at DESC, b.id DESC"
	searchQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
	args = append(args, perPage+1)

	rows, err := db.Conn.Query(r.Context(), searchQuery, args...)
	if err != nil {
		log.Printf("Failed to search bookings: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var bookings []Booking
	var bookingIDs []string

	for rows.Next() {
		var b Booking
		var userFullName string
		var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
		var depositRequired bool

		if err := rows.Scan(
			&b.ID, &b.StartTime, &b.Status, &userFullName,
			&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
			&depositRequired, &preStartAmountPaid,
			&b.OutOfHours,
		); err != nil {
			log.Printf("Failed to scan booking row: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		b.User = &UserSummary{FullName: userFullName}
		b.TotalAmount = totalAmount
		b.AmountPaid = amountPaid
		b.AmountDue = amountDue
		populateDepositFields(&b, depositRequired, preStartAmountPaid)
		bookings = append(bookings, b)
		bookingIDs = append(bookingIDs, b.ID)
	}
	rows.Close()

	// Count query runs AFTER the data query result set is consumed to avoid pgx "conn busy".
	var total int
	{
		countSQL := `SELECT COUNT(*) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE
			b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR
			b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR
			u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR
			u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR
			EXISTS (SELECT 1 FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\') OR
			EXISTS (SELECT 1 FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\')`
		if err := db.Conn.QueryRow(r.Context(), countSQL, searchPattern).Scan(&total); err != nil {
			log.Printf("Failed to count search bookings: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT booking_id, name FROM (
				SELECT bs.booking_id, s.name
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = ANY($1)
				UNION ALL
				SELECT bcs.booking_id, cs.name
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = ANY($1)
			) sub ORDER BY booking_id, name
		`, bookingIDs)
		if err != nil {
			log.Printf("Failed to fetch booking services: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		defer serviceRows.Close()

		servicesByBooking := make(map[string][]BookingService)
		for serviceRows.Next() {
			var bookingID, serviceName string
			if err := serviceRows.Scan(&bookingID, &serviceName); err != nil {
				log.Printf("Failed to scan service row: %v", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			n := serviceName
			servicesByBooking[bookingID] = append(servicesByBooking[bookingID], BookingService{
				BookingID:   bookingID,
				ServiceName: &n,
			})
		}
		for i := range bookings {
			if services, exists := servicesByBooking[bookings[i].ID]; exists {
				bookings[i].Services = services
			} else {
				bookings[i].Services = []BookingService{}
			}
		}
	}

	// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
	// The extra item is discarded; the cursor points to the last real item.
	var nextCursor *string
	if len(bookings) > perPage {
		bookings = bookings[:perPage]
		last := bookings[len(bookings)-1]
		cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
		nextCursor = &cursor
	}

	totalPages := (total + perPage - 1) / perPage
	if totalPages == 0 {
		totalPages = 1
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings:   bookings,
		PerPage:    perPage,
		Total:      total,
		TotalPages: totalPages,
		NextCursor: nextCursor,
	}); err != nil {
		log.Printf("Failed to encode response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// POST /api/bookings
func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
	var req CreateBookingRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}

	// Extract idempotency key from header
	idempotencyKey := r.Header.Get("Idempotency-Key")

	// If idempotency key provided, check for existing booking
	if idempotencyKey != "" {
		var existingBooking Booking
		existingBooking.User = &UserSummary{}
		err := db.Conn.QueryRow(r.Context(), `
			SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
			FROM bookings b WHERE b.idempotency_key = $1
		`, idempotencyKey).Scan(
			&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
			&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
			&existingBooking.DepositRequired,
		)
		if err == nil {
			// Booking already exists with this key — fetch services and return it
			rows, err := db.Conn.Query(r.Context(), `
				SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
				       s.name, s.description, s.price, s.duration_minutes
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = $1
				UNION ALL
				SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
				       cs.name, cs.description, cs.price, cs.duration_minutes
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = $1
			`, existingBooking.ID)
			if err == nil {
				for rows.Next() {
					var bs BookingService
					if err := rows.Scan(
						&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
						&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
					); err != nil {
						break
					}
					existingBooking.Services = append(existingBooking.Services, bs)
				}
				rows.Close()
			}

			// Get payment info
			var preStartPaid float64
//nolint:errcheck // zero value is acceptable fallback on scan failure
			_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid)
			populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			_ = json.NewEncoder(w).Encode(existingBooking)
			return
		}
		// If err is sql.ErrNoRows, proceed with creation
	}

	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	isGuest := false

	if !ok || userID == "" {
		if req.UserID == nil || *req.UserID == "" {
			http.Error(w, "Authentication required", http.StatusUnauthorized)
			return
		}

		var accountRole string
		if err := db.Conn.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
			http.Error(w, "User not found", http.StatusBadRequest)
			return
		}
		if accountRole != "guest" {
			http.Error(w, "Invalid user_id - guest account required", http.StatusBadRequest)
			return
		}
		userID = *req.UserID
		isGuest = true
	}

	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if len(req.ServiceIDs) == 0 {
		http.Error(w, "At least one service is required", http.StatusBadRequest)
		return
	}

	var depositsRequired int
	if !isGuest {
		if err := db.Conn.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
			log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if !isGuest && depositsRequired > 0 {
		var activeCount int
		if err := db.Conn.QueryRow(r.Context(), `
			SELECT COUNT(*) FROM bookings
			WHERE user_id = $1 AND status IN ('pending', 'confirmed')
		`, userID).Scan(&activeCount); err != nil {
			log.Printf("Failed to check active bookings for user %s: %v", userID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if activeCount > 0 {
			http.Error(w, "You already have an active booking. Complete or cancel it before creating a new one.", http.StatusConflict)
			return
		}
	}

	// Check 1h minimum advance for all users
	if req.StartTime.Before(clock.Now().Add(1 * time.Hour)) {
		http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
		return
	}

	if !isGuest && depositsRequired > 0 && req.StartTime.Before(clock.Now().Add(payments.DepositAdvanceWindow)) {
		http.Error(w, fmt.Sprintf("When deposits are required, bookings must be made at least %.0f hours in advance to allow time for payment.", payments.DepositAdvanceWindow.Hours()), http.StatusBadRequest)
		return
	}

	// Snapshot whether a deposit is required at the moment of booking creation.
	// Stored on the bookings row so historic GET responses are accurate regardless
	// of the user's future deposits_required changes.
	depositRequiredSnapshot := depositsRequired > 0

	if !isGuest && len(req.ServiceIDs) > 0 {
		patchTestRows, err := db.Conn.Query(r.Context(), `
			SELECT id, service_ids, notice_duration_hours, expiry_months
			FROM patch_tests
			WHERE service_ids::text[] && $1::text[]
		`, req.ServiceIDs)
		if err != nil {
			log.Printf("Failed to query patch tests: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		type ptInfo struct {
			id           string
			noticeHours  int
			expiryMonths int
		}
		patchTestsByService := make(map[string]ptInfo)
		var allPtIDs []string
		for patchTestRows.Next() {
			var id string
			var serviceIDs []string
			var noticeHours, expiryMonths int
			if err := patchTestRows.Scan(&id, &serviceIDs, &noticeHours, &expiryMonths); err != nil {
				log.Printf("Failed to scan patch test: %v", err)
				continue
			}
			allPtIDs = append(allPtIDs, id)
			for _, sid := range serviceIDs {
				patchTestsByService[sid] = ptInfo{id, noticeHours, expiryMonths}
			}
		}
		patchTestRows.Close()

		userPatchTests := make(map[string]time.Time)
		if len(allPtIDs) > 0 {
			uptRows, err := db.Conn.Query(r.Context(), `
				SELECT patch_test_id, tested_at
				FROM user_patch_tests
				WHERE user_id = $1 AND patch_test_id = ANY($2)
			`, userID, allPtIDs)
			if err != nil {
				log.Printf("Failed to query user patch tests: %v", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			for uptRows.Next() {
				var ptID string
				var testedAt time.Time
				if err := uptRows.Scan(&ptID, &testedAt); err != nil {
					log.Printf("Failed to scan user patch test: %v", err)
					continue
				}
				userPatchTests[ptID] = testedAt
			}
			uptRows.Close()
		}

		for _, serviceID := range req.ServiceIDs {
			pt, needsPatch := patchTestsByService[serviceID]
			if !needsPatch {
				continue
			}

			testedAt, hasTest := userPatchTests[pt.id]
			if !hasTest {
				http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
				return
			}

			eligibleFrom := testedAt.Add(time.Duration(pt.noticeHours) * time.Hour)
			if req.StartTime.Before(eligibleFrom) {
				hoursLeft := eligibleFrom.Sub(req.StartTime).Hours()
				http.Error(w, fmt.Sprintf("Booking time is before the %.0f hour notice period after patch test. Earliest booking: %s", hoursLeft, eligibleFrom.Format("2006-01-02 15:04")), http.StatusBadRequest)
				return
			}

			if pt.expiryMonths > 0 {
				expiresAt := testedAt.AddDate(0, pt.expiryMonths, 0)
				if req.StartTime.After(expiresAt) {
					http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
					return
				}
			}
		}
	}

	if req.StartTime.Before(clock.Now()) {
		http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
		return
	}

	var svcDuration int
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
	`, req.ServiceIDs).Scan(&svcDuration); err != nil {
		log.Printf("Failed to calc duration: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	localStart := req.StartTime.In(londonLocation)
	// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
	weekday := int((localStart.Weekday() + 6) % 7)
	var closeStr string
	if err := db.Conn.QueryRow(r.Context(), `SELECT end_time FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
		log.Printf("Failed to get hours: %v", err)
		http.Error(w, "Could not verify hours", http.StatusInternalServerError)
		return
	}

	endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
	localEndLondon := localStart.Add(time.Duration(svcDuration) * time.Minute).In(londonLocation)
	if err := checkClosingHours(localEndLondon, closeStr); err != nil {
		http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
		return
	}

	// Clean up any existing reservation for this user BEFORE the transaction
	// and time-blocker check. The reservation was created by the reserve step
	// (POST /api/bookings/reserve) and is stored in the time_blockers table.
	// If not deleted here, CheckTimeBlockerOverlap below would detect this
	// reservation as a conflict and reject the booking — a self-blocking race.
	// Using db.Conn.Exec (not tx.Exec) so the delete is visible to the
	// separate connection used by CheckTimeBlockerOverlap.
	if _, err := db.Conn.Exec(r.Context(), `
		DELETE FROM time_blockers
		WHERE description LIKE 'RESERVATION:%'
		  AND (created_by = $1
		       OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
	`, userID, req.StartTime); err != nil {
		log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		log.Printf("Failed to start transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	// Evict any pending_release bookings that overlap this slot.
	if _, err := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, endTime); err != nil {
		log.Printf("Failed to evict pending_release bookings for slot %s: %v", req.StartTime, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var cnt int
	if err := tx.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
		AND start_time < $2
		AND end_time > $1
	`, req.StartTime, endTime).Scan(&cnt); err != nil {
		log.Printf("Failed to check slot overlap: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if cnt > 0 {
		http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
		return
	}

	// Check for time blocker overlap (read-only, uses its own connection, safe to call in-tx)
	// Pass excludeUserID so the user's own RESERVATION doesn't self-block
	blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime, &userID)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	} else if blockerOverlap {
		http.Error(w, "Cannot book this time - slot is blocked", http.StatusConflict)
		return
	}

	var createdBy *string
	if creatorID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
		createdBy = &creatorID
	}

	// Delete any existing reservation for this user (max 1 per user)
	// Also matches anon reservations by start_time for users who register mid-flow
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers
		WHERE description LIKE 'RESERVATION:%'
		  AND (created_by = $1
		       OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
	`, userID, req.StartTime); err != nil {
		log.Printf("Failed to delete reservation time blocker for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Insert booking with snapshotted deposit_required
	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		INSERT INTO bookings (user_id, start_time, notes, created_by, deposit_required, status, idempotency_key)
		VALUES ($1, $2, $3::text, $4, $5, CASE WHEN $3::text IS NOT NULL AND $3::text != '' THEN 'pending'::booking_status ELSE 'confirmed'::booking_status END, $6)
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, deposit_required
	`, userID, req.StartTime, req.Notes, createdBy, depositRequiredSnapshot, sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""}).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
		&booking.DepositRequired,
	); err != nil {
		log.Printf("Failed to create booking for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if len(req.ServiceIDs) > 0 {
		if _, err := tx.Exec(r.Context(), `
			INSERT INTO booking_services (booking_id, service_id)
			SELECT $1, unnest($2::text[])
		`, booking.ID, req.ServiceIDs); err != nil {
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	// Always create low-priority notification for all bookings
	if _, err := tx.Exec(r.Context(), `
		INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('new_booking', $1, $2)
	`, booking.ID, userID); err != nil {
		log.Printf("Failed to create admin notification for booking %s: %v", booking.ID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// If booking needs approval (has notes or is for today), also create high-priority notification
	needsApproval := false
	if req.Notes != nil && *req.Notes != "" {
		needsApproval = true
	} else {
		londonNow := clock.Now().In(londonLocation)
		londonBookingDay := req.StartTime.In(londonLocation)
		if londonNow.Year() == londonBookingDay.Year() && londonNow.YearDay() == londonBookingDay.YearDay() {
			needsApproval = true
		}
	}

	if needsApproval {
		if _, err := tx.Exec(r.Context(), `
			INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2)
		`, booking.ID, userID); err != nil {
			log.Printf("Failed to create pending approval notification for booking %s: %v", booking.ID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	if err := tx.Commit(r.Context()); err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Fetch services for the response
	booking.Services = []BookingService{}
	rows, err := db.Conn.Query(r.Context(), `
		SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
		       s.name, s.description, s.price, s.duration_minutes
		FROM booking_services bs
		JOIN services s ON bs.service_id = s.id
		WHERE bs.booking_id = $1
	`, booking.ID)
	if err == nil {
		defer rows.Close()
		for rows.Next() {
			var bs BookingService
			if err := rows.Scan(
				&bs.BookingID, &bs.ServiceID, &bs.OverridePrice, &bs.OverrideDurationMinutes,
				&bs.ServiceName, &bs.ServiceDescription, &bs.Price, &bs.DurationMinutes,
			); err != nil {
				break
			}
			booking.Services = append(booking.Services, bs)
		}
	}

	// Populate deposit display fields on the creation response.
	// No payments exist yet so pre-start paid is 0 and DepositPaid will be false.
	populateDepositFields(&booking, depositRequiredSnapshot, 0)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// PUT /api/bookings/{id}
func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || userID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	var req EditBookingRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}
	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if req.StartTime.Before(clock.Now()) {
		http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
		return
	}

	var currentStatus string
	if err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found or access denied", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
		http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
		return
	}

	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		log.Printf("Failed to start transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	var durationMinutes int
	if err := tx.QueryRow(r.Context(), `
		SELECT total_duration_minutes FROM bookings WHERE id = $1
	`, bookingID).Scan(&durationMinutes); err != nil {
		log.Printf("Failed to get booking duration %s: %v", bookingID, err)
		durationMinutes = 60
	}

	newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)

	// Evict any pending_release bookings that overlap this slot.
	if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
		log.Printf("Failed to evict pending_release bookings on edit: %v", evictErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var overlapCount int
	if err := tx.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings
		WHERE id != $1
		AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
		AND start_time < $3
		AND end_time > $2
	`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
		log.Printf("Failed to check overlap %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if overlapCount > 0 {
		http.Error(w, "Cannot edit - this time slot is taken", http.StatusConflict)
		return
	}

	blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime, &userID)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	} else if blockerOverlap {
		http.Error(w, "Cannot edit this time - slot is blocked", http.StatusConflict)
		return
	}

	localStart := req.StartTime.In(londonLocation)
	// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
	weekday := int((localStart.Weekday() + 6) % 7)
	bookingTime := localStart.Format("15:04:05")
	daysToMonday := int(localStart.Weekday())
	if daysToMonday == 0 {
		daysToMonday = 7
	}
	tm := localStart.AddDate(0, 0, -daysToMonday+1)
	// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
	// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
	// return London calendar values. Creating a UTC midnight of those values produces
	// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
	// extracts the calendar date from the time's own location — so this maps correctly
	// to ega.week_start (DATE column), regardless of BST/GMT.
	weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)

	var isClosed bool
	if err := tx.QueryRow(r.Context(), `
		SELECT EXISTS (
			SELECT 1 FROM exceptional_working_hours ewh
			JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
			WHERE ega.week_start = $1
			  AND ewh.weekday = $2
			  AND ewh.is_open = false
			  AND ewh.start_time <= $3
			  AND ewh.end_time >= $3
		)
	`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
		log.Printf("Failed to check exceptional hours: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if isClosed {
		http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		UPDATE bookings
		SET start_time = $1, updated_at = NOW()
		WHERE id = $2 AND user_id = $3
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
	`, req.StartTime, bookingID, userID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found or access denied", http.StatusNotFound)
			return
		}
		log.Printf("Failed to update booking %s for user %s: %v", bookingID, userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if err := tx.Commit(r.Context()); err != nil {
		log.Printf("Failed to commit transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// PUT /api/bookings/{id}/progress
func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	var req ProgressBookingRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}
	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	// M8
	// L5

	allowed := map[string]bool{
		"pending": true, "confirmed": true, "in_progress": true,
		"completed": true, "client_cancelled": true, "we_cancelled": true,
		"no_show": true,
	}
	if !allowed[req.Status] {
		http.Error(w, "Invalid status", http.StatusBadRequest)
		return
	}

	tx, txErr := db.Conn.Begin(r.Context())
	if txErr != nil {
		log.Printf("Failed to begin transaction for booking progress: %v", txErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	// Read current status before updating to validate the transition
	var currentStatus string
	if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&currentStatus); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch current status for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	validTransitions := map[string]map[string]bool{
		"pending":         {"confirmed": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
		"confirmed":       {"in_progress": true, "completed": true, "client_cancelled": true, "we_cancelled": true},
		"in_progress":     {"completed": true},
		"pending_release": {"pending": true, "confirmed": true, "client_cancelled": true, "we_cancelled": true},
		"no_show":         {},
		"deposit_lapsed":  {},
	}
	if targets, ok := validTransitions[currentStatus]; ok {
		if !targets[req.Status] {
			http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
			return
		}
	} else if currentStatus != req.Status {
		http.Error(w, fmt.Sprintf("Cannot transition booking from '%s' to '%s'", currentStatus, req.Status), http.StatusBadRequest)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		UPDATE bookings
		SET status = $1, updated_at = NOW()
		WHERE id = $2
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
	`, req.Status, bookingID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to update booking status for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if req.Status == "completed" {
		if currentStatus == "completed" {
			log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
		} else {
			// Collect patch test IDs first so the rows are consumed before INSERT operations.
			var patchTestIDs []string
			ptRows, err := tx.Query(r.Context(), `
			SELECT DISTINCT pt.id
			FROM patch_tests pt
			JOIN booking_services bs ON bs.booking_id = $1
			WHERE pt.id IN (
				SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
			)
		`, bookingID)
			if err != nil {
				log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
			} else {
				for ptRows.Next() {
					var ptID string
					if err := ptRows.Scan(&ptID); err == nil {
						patchTestIDs = append(patchTestIDs, ptID)
					}
				}
				ptRows.Close()
			}
			for _, ptID := range patchTestIDs {
				if _, err := tx.Exec(r.Context(), `
				INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
				VALUES ($1, $2, NOW())
				ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
			`, booking.User.ID, ptID); err != nil {
					log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err)
				}
			}

			var bookingTotal float64
			if err := tx.QueryRow(r.Context(), `
			SELECT total_amount FROM bookings WHERE id = $1
		`, bookingID).Scan(&bookingTotal); err != nil {
				log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
			}

			// Don't award a stamp if this booking already used a loyalty redemption
			// (take or receive, never both).
			var loyaltyAppliedOnThisBooking bool
//nolint:errcheck // zero value is acceptable fallback on scan failure
			_ = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking)

			var newStampCount int
			if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
				if err := tx.QueryRow(r.Context(), `
				UPDATE users
				SET loyalty_stamps = loyalty_stamps + 1
				WHERE id = $1
				AND NOT EXISTS (
					SELECT 1 FROM bookings b
					WHERE b.user_id = users.id
					AND b.status = 'completed'
					AND b.updated_at >= CURRENT_DATE - INTERVAL '1 day'
					AND b.id != $2
				)
				RETURNING loyalty_stamps
			`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
					if !errors.Is(err, pgx.ErrNoRows) {
						log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
					}
				}
			}

			// Create pending redemption when stamps reach LoyaltyStampCost
			if newStampCount == payments.LoyaltyStampCost {
				_, err = tx.Exec(r.Context(), `
				INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
				VALUES ($1, $2, 'pending', NOW())
			`, booking.User.ID, payments.LoyaltyStampCost)
				if err != nil {
					log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
				}
			}

			// Skip time-based campaign if already applied at payment time
			var timeBasedApplied bool
//nolint:errcheck // zero value is acceptable fallback on scan failure
			_ = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied)
			if bookingTotal > 0 && !timeBasedApplied {
				var campaignID string
				var campaignPercent float64
				if err := tx.QueryRow(r.Context(), `
				SELECT id, discount_percent FROM discount_campaigns
				WHERE status = 'active' AND campaign_type = 'time_based'
				AND start_date <= NOW() AND end_date >= NOW()
				AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
				ORDER BY discount_percent DESC LIMIT 1
			`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
					discountAmount := roundTo2(bookingTotal * campaignPercent / 100)

					if _, err := tx.Exec(r.Context(), `
					INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
					VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
				`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
						log.Printf("ALERT: failed to insert booking discount: %v", err)
					}

					if _, err := tx.Exec(r.Context(), `
					INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
					VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
				`, bookingID, discountAmount, booking.User.ID); err != nil {
						log.Printf("ALERT: failed to insert payment record: %v", err)
					}

					if _, err := tx.Exec(r.Context(), `
					UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
				`, campaignID); err != nil {
						log.Printf("ALERT: failed to update discount campaign usage: %v", err)
					}
				}
			}

			if bookingTotal > 0 {
				var userBookingCount int
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)

				var milestoneCampaignID string
				var milestonePercent float64
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = tx.QueryRow(r.Context(), `
				SELECT id, discount_percent FROM discount_campaigns
				WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
				AND milestone_value = $1
				AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
			`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent)

				if milestoneCampaignID != "" {
					discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
					if _, err := tx.Exec(r.Context(), `
					INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
					VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
				`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
						log.Printf("ALERT: failed to insert booking discount: %v", err)
					}
					if _, err := tx.Exec(r.Context(), `
					INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
					VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
				`, bookingID, discountAmount, booking.User.ID); err != nil {
						log.Printf("ALERT: failed to insert payment record: %v", err)
					}
					if _, err := tx.Exec(r.Context(), `
					UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
				`, milestoneCampaignID); err != nil {
						log.Printf("ALERT: failed to insert payment record: %v", err)
					}
				}

				var globalMilestoneApplied bool
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied)
				if !globalMilestoneApplied {
					var globalCount int
//nolint:errcheck // zero value is acceptable fallback on scan failure
					_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)

					var hasInPersonPayment bool
//nolint:errcheck // zero value is acceptable fallback on scan failure
					_ = tx.QueryRow(r.Context(), `
					SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)

					if hasInPersonPayment {
						var globalCampaignID string
						var globalPercent float64
//nolint:errcheck // zero value is acceptable fallback on scan failure
						_ = tx.QueryRow(r.Context(), `
						SELECT id, discount_percent FROM discount_campaigns
						WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
						AND milestone_value <= $1
						AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
						ORDER BY milestone_value DESC LIMIT 1
					`, globalCount).Scan(&globalCampaignID, &globalPercent)

						if globalCampaignID != "" {
							discountAmount := roundTo2(bookingTotal * globalPercent / 100)
							if _, err := tx.Exec(r.Context(), `
							INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
							VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
						`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
								log.Printf("ALERT: failed to insert booking discount: %v", err)
							}
							if _, err := tx.Exec(r.Context(), `
							INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
							VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
						`, bookingID, discountAmount, booking.User.ID); err != nil {
								log.Printf("ALERT: failed to insert payment record: %v", err)
							}
							if _, err := tx.Exec(r.Context(), `
							UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
						`, globalCampaignID); err != nil {
								log.Printf("ALERT: failed to insert payment record: %v", err)
							}
						}
					}
				}

				var firstVisitDate time.Time
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
				if !firstVisitDate.IsZero() {
					annRows, err := tx.Query(r.Context(), `
					SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
					WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
					AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
				`, booking.User.ID)
					if err == nil {
						// Collect anniversary campaigns first to avoid interleaving rows with writes.
						type annCampaign struct {
							id    string
							pct   float64
							value int
							unit  string
						}
						var campaigns []annCampaign
						for annRows.Next() {
							var c annCampaign
							if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
								campaigns = append(campaigns, c)
							}
						}
						annRows.Close()

						// Sort by milestone_value descending so we apply the longest anniversary only
						sort.Slice(campaigns, func(i, j int) bool {
							return campaigns[i].value > campaigns[j].value
						})
						for _, c := range campaigns {
							var matches bool
							elapsed := time.Since(firstVisitDate)
							switch c.unit {
							case "months":
								months := int(elapsed.Hours() / (30 * 24))
								matches = months >= c.value
							case "years":
								years := int(elapsed.Hours() / (365.25 * 24))
								matches = years >= c.value
							}
							if matches {
								discountAmount := roundTo2(bookingTotal * c.pct / 100)
								if _, err := tx.Exec(r.Context(), `
								INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
								VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
							`, bookingID, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
									log.Printf("ALERT: failed to insert booking discount: %v", err)
								}
								if _, err := tx.Exec(r.Context(), `
								INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
								VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
							`, bookingID, discountAmount, booking.User.ID); err != nil {
									log.Printf("ALERT: failed to insert payment record: %v", err)
								}
								if _, err := tx.Exec(r.Context(), `
								UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
							`, c.id); err != nil {
									log.Printf("ALERT: failed to insert payment record: %v", err)
								}
								break // apply longest matching only
							}
						}
					}
				}
			}

			var paymentExists bool
			if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
				var newDepositsRequired int
				if err := tx.QueryRow(r.Context(), `
				UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
				WHERE id = $1
				RETURNING deposits_required
			`, booking.User.ID).Scan(&newDepositsRequired); err != nil {
					log.Printf("ALERT: failed to update deposits_required: %v", err)
				} else if newDepositsRequired == 0 {
					// After 3 paid bookings, forget no-shows so the counter resets.
					if _, err := tx.Exec(r.Context(), `
					INSERT INTO forgiven_no_shows (booking_id)
					SELECT id FROM bookings
					WHERE user_id = $1 AND status = 'no_show'
					  AND start_time >= NOW() - INTERVAL '6 months'
					  AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
				`, booking.User.ID); err != nil {
						log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
					}
				}
			}
			// Consume unconsumed name_history entries — this booking is the "first post-name-change
			// booking" that completes. After this, we no longer show "formerly" on displays.
			if _, err := tx.Exec(r.Context(), `
			UPDATE name_history SET booking_id = $1
			WHERE user_id = $2 AND booking_id IS NULL
		`, bookingID, booking.User.ID); err != nil {
				log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
			}
		} // close the else from alreadyCompleted check
	}

	if err := tx.Commit(r.Context()); err != nil {
		log.Printf("Failed to commit booking progress: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// POST /api/bookings/{id}/confirm
func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	var req ConfirmBookingRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}
	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	for _, override := range req.ServiceOverrides {
		if override.OverridePrice != nil && *override.OverridePrice < 0 {
			http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
			return
		}
		if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
			http.Error(w, "Override duration must be positive", http.StatusBadRequest)
			return
		}
	}

	for _, override := range req.CustomServiceOverrides {
		if override.OverridePrice != nil && *override.OverridePrice < 0 {
			http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
			return
		}
		if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
			http.Error(w, "Override duration must be positive", http.StatusBadRequest)
			return
		}
	}

	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		log.Printf("Failed to start transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	var bkStart time.Time
	var dur int
	if err := tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
		log.Printf("Failed to get start time: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if err := tx.QueryRow(r.Context(), `
		SELECT total_duration_minutes FROM bookings WHERE id = $1
	`, bookingID).Scan(&dur); err != nil {
		log.Printf("Failed to calculate duration on confirm: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	endTime := bkStart.Add(time.Duration(dur) * time.Minute)

	if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, bkStart, endTime); evictErr != nil {
		log.Printf("Failed to evict pending_release bookings on confirm: %v", evictErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var cnt int
	if err := tx.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('pending','confirmed','in_progress','completed')
		AND start_time < $3
		AND end_time > $2
	`, bookingID, bkStart, endTime).Scan(&cnt); err != nil {
		log.Printf("Failed to check overlap on confirm: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if cnt > 0 {
		http.Error(w, "Cannot confirm - time slot overlaps with an existing booking", http.StatusConflict)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		UPDATE bookings
		SET status = 'confirmed', notes = COALESCE($1, notes), updated_at = NOW()
		WHERE id = $2 AND status = 'pending'
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
	`, req.Notes, bookingID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found or already confirmed", http.StatusNotFound)
			return
		}
		log.Printf("Failed to confirm booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if len(req.ServiceOverrides) > 0 {
		serviceIDs := make([]string, len(req.ServiceOverrides))
		for i, o := range req.ServiceOverrides {
			serviceIDs[i] = o.ServiceID
		}
		var count int
		if err := tx.QueryRow(r.Context(), `
			SELECT COUNT(*) FROM booking_services WHERE booking_id = $1 AND service_id = ANY($2)
		`, bookingID, serviceIDs).Scan(&count); err != nil {
			log.Printf("Failed to verify services for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if count != len(req.ServiceOverrides) {
			http.Error(w, "One or more service IDs do not belong to this booking", http.StatusBadRequest)
			return
		}
		for _, override := range req.ServiceOverrides {
			if _, err := tx.Exec(r.Context(), `
				UPDATE booking_services
				SET override_price = $1, override_duration_minutes = $2
				WHERE booking_id = $3 AND service_id = $4
			`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
				log.Printf("Failed to update service override for booking %s, service %s: %v", bookingID, override.ServiceID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}
	}

	if len(req.CustomServiceOverrides) > 0 {
		customServiceIDs := make([]string, len(req.CustomServiceOverrides))
		for i, o := range req.CustomServiceOverrides {
			customServiceIDs[i] = o.ServiceID
		}
		var customCount int
		if err := tx.QueryRow(r.Context(), `
			SELECT COUNT(*) FROM booking_custom_services WHERE booking_id = $1 AND custom_service_id = ANY($2)
		`, bookingID, customServiceIDs).Scan(&customCount); err != nil {
			log.Printf("Failed to verify custom services for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if customCount != len(req.CustomServiceOverrides) {
			http.Error(w, "One or more custom service IDs do not belong to this booking", http.StatusBadRequest)
			return
		}
		for _, override := range req.CustomServiceOverrides {
			if _, err := tx.Exec(r.Context(), `
				UPDATE booking_custom_services
				SET override_price = $1, override_duration_minutes = $2
				WHERE booking_id = $3 AND custom_service_id = $4
			`, override.OverridePrice, override.OverrideDurationMinutes, bookingID, override.ServiceID); err != nil {
				log.Printf("Failed to update custom service override for booking %s, custom service %s: %v", bookingID, override.ServiceID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}
	}

	if err := tx.Commit(r.Context()); err != nil {
		log.Printf("Failed to commit booking confirmation: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if dav.Service != nil {
		var durationMinutes int
//nolint:errcheck // zero value is acceptable fallback on scan failure
		_ = db.Conn.QueryRow(r.Context(), `
			SELECT total_duration_minutes FROM bookings WHERE id = $1
		`, bookingID).Scan(&durationMinutes)
		if durationMinutes == 0 {
			durationMinutes = 60
		}
		_ = dav.Service.CreateEvent(1, dav.EventInput{
			Summary: "Crussell Booking",
			Start:   booking.StartTime,
			End:     booking.StartTime.Add(time.Duration(durationMinutes) * time.Minute),
		})
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// DELETE /api/bookings/{id}
func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || userID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	var paymentExists bool
	if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil {
		log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if paymentExists {
		var req DeleteBookingRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			log.Printf("Failed to decode request: %v", err)
			http.Error(w, "Invalid request", http.StatusBadRequest)
			return
		}
		if err := validators.Validate.Struct(&req); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		allowed := map[string]bool{
			"client_cancelled": true, "we_cancelled": true,
		}
		if !allowed[req.Reason] {
			http.Error(w, "Invalid reason", http.StatusBadRequest)
			return
		}

		// Get booking info needed for refund (before any transaction)
		var originalStatus string
		var startTime time.Time
		if err := db.Conn.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
			if errors.Is(err, pgx.ErrNoRows) {
				http.Error(w, "Booking not found or access denied", http.StatusNotFound)
				return
			}
			log.Printf("Failed to get booking status %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		// Process refund FIRST — if it fails, the booking stays active and the user can retry
		var refundResult *payments.RefundCalculationResult
		paySvc := payments.NewPaymentService()
		payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
		if payErr == nil && payInfo.TotalPaid > 0 {
			var calcErr error
			refundResult, calcErr = payments.ProcessCancellationRefund(
				r.Context(), bookingID, payInfo.TotalAmount, payInfo.TotalPaid,
				startTime, clock.Now(), "client_cancelled", nil,
			)
			if calcErr != nil {
				log.Printf("Refund processing failed for booking %s — cancellation aborted: %v", bookingID, calcErr)
				w.Header().Set("Content-Type", "application/json")
				w.WriteHeader(http.StatusInternalServerError)
				_ = json.NewEncoder(w).Encode(map[string]string{
					"error": "Refund processing failed — cancellation aborted. Please try again or contact support.",
				})
				return
			}
		}

		// Refund succeeded (or no refund needed) — now cancel the booking
		tx, err := db.Conn.Begin(r.Context())
		if err != nil {
			log.Printf("Failed to start transaction: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

		result, err := tx.Exec(r.Context(), `
			UPDATE bookings SET status = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3
		`, req.Reason, bookingID, userID)
		if err != nil {
			log.Printf("Failed to cancel booking %s for user %s: %v", bookingID, userID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if result.RowsAffected() == 0 {
			http.Error(w, "Booking not found or access denied", http.StatusNotFound)
			return
		}

		if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		if originalStatus == "confirmed" {
			// Only apply no-show logic for future bookings — past bookings
			// that happen to still be "confirmed" should not retroactively
			// receive a no-show penalty when cancelled after the fact.
			noticeHours := startTime.Sub(clock.Now()).Hours()

			if startTime.After(clock.Now()) && noticeHours < 24 {
				isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow

				if !isForgiving {
					if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID); err != nil {
						log.Printf("Failed to update no-show status for booking %s: %v", bookingID, err)
					}
				} else {
					if _, err := tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
						log.Printf("Failed to update client_cancelled status for booking %s: %v", bookingID, err)
					}
				}
			}

			if _, err := tx.Exec(r.Context(), `
				INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ($1, $2, $3)
			`, "cancelled_booking", bookingID, userID); err != nil {
				log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}

		if err := tx.Commit(r.Context()); err != nil {
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		// After a no-show is recorded, check if user now has 2+ no-shows in 6 months.
		// Only apply for future bookings — past confirmed bookings should not
		// trigger deposit requirements when cancelled after the fact.
		if originalStatus == "confirmed" && startTime.After(clock.Now()) && startTime.Sub(clock.Now()).Hours() < 24 && !(req.ForgiveNoShow != nil && *req.ForgiveNoShow) {
			if applied, err := ApplyDepositsIfNeeded(r.Context(), db.Conn, userID); err != nil {
				log.Printf("Failed to check deposits after no-show for user %s: %v", userID, err)
			} else if applied {
				log.Printf("Deposits required set to 3 for user %s due to 2+ no-shows in 6 months", userID)
			}
		}

		resp := map[string]any{
			"message": "Booking cancelled successfully",
			"id":      bookingID,
			"status":  req.Reason,
		}
		if refundResult != nil && refundResult.RefundableAmount > 0 {
			resp["refund_calculation"] = refundResult
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		_ = json.NewEncoder(w).Encode(resp)
		return
	}

	// Hard delete — no payments exist
	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		log.Printf("Failed to start transaction: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	if _, err := tx.Exec(r.Context(), `DELETE FROM admin_notifications WHERE booking_id = $1`, bookingID); err != nil {
		log.Printf("Failed to delete admin notifications for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	result, err := tx.Exec(r.Context(), "DELETE FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID)
	if err != nil {
		log.Printf("Failed to delete booking %s for user %s: %v", bookingID, userID, err)
		http.Error(w, "Failed to delete booking", http.StatusInternalServerError)
		return
	}
	if result.RowsAffected() == 0 {
		http.Error(w, "Booking not found or access denied", http.StatusNotFound)
		return
	}

	if err := tx.Commit(r.Context()); err != nil {
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(map[string]any{
		"message": "Booking deleted successfully",
		"id":      bookingID,
	})
}

// GET /api/bookings/{id}
func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || userID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	var booking Booking
	booking.Payments = []Payment{}
	booking.Services = []BookingService{}
	booking.User = &UserSummary{}

	var createdBy sql.NullString
	var depositRequired bool

	if err := db.Conn.QueryRow(r.Context(), `
        SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
               b.deposit_required
        FROM bookings b
        WHERE b.id = $1 AND b.user_id = $2
    `, bookingID, userID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
		&depositRequired,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found or access denied", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch booking %s for user %s: %v", bookingID, userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if createdBy.Valid {
		booking.CreatedBy = &createdBy.String
	}

	serviceRows, err := db.Conn.Query(r.Context(), `
        SELECT
            bs.service_id, bs.override_price, bs.override_duration_minutes,
            s.name, s.description, s.price, s.duration_minutes
        FROM booking_services bs
        LEFT JOIN services s ON bs.service_id = s.id
        WHERE bs.booking_id = $1
        UNION ALL
        SELECT
            bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
            cs.name, cs.description, cs.price, cs.duration_minutes
        FROM booking_custom_services bcs
        LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
        WHERE bcs.booking_id = $1
        ORDER BY name
    `, bookingID)
	if err != nil {
		log.Printf("Failed to fetch services for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer serviceRows.Close()

	var totalAmount float64
	var durationMinutes int
	for serviceRows.Next() {
		var s BookingService
		var overridePrice sql.NullFloat64
		var overrideDuration sql.NullInt32
		var name, description sql.NullString
		var basePrice sql.NullFloat64
		var baseDuration sql.NullInt32

		if err := serviceRows.Scan(
			&s.ServiceID,
			&overridePrice, &overrideDuration,
			&name, &description, &basePrice, &baseDuration,
		); err != nil {
			log.Printf("Failed to scan service row for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		var priceToAdd float64
		var durationToAdd int
		if overridePrice.Valid {
			s.OverridePrice = &overridePrice.Float64
			priceToAdd = overridePrice.Float64
		} else if basePrice.Valid {
			priceToAdd = basePrice.Float64
		}
		if overrideDuration.Valid {
			d := int(overrideDuration.Int32)
			s.OverrideDurationMinutes = &d
			durationToAdd = d
		} else if baseDuration.Valid {
			durationToAdd = int(baseDuration.Int32)
		}

		totalAmount += priceToAdd
		durationMinutes += durationToAdd

		if name.Valid {
			s.ServiceName = &name.String
		}
		if description.Valid {
			s.ServiceDescription = &description.String
		}
		if basePrice.Valid {
			s.Price = &basePrice.Float64
		}
		if baseDuration.Valid {
			d := int(baseDuration.Int32)
			s.DurationMinutes = &d
		}
		booking.Services = append(booking.Services, s)
	}

	paymentRows, err := db.Conn.Query(r.Context(), `
        SELECT
            id, payment_type, payment_method, vendor_code, invoice_number,
            status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
            created_at, updated_at, created_by
        FROM payments
        WHERE booking_id = $1
        ORDER BY created_at ASC
    `, bookingID)
	if err != nil {
		log.Printf("Failed to fetch payments for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer paymentRows.Close()

	var amountPaid, preStartAmountPaid float64
	for paymentRows.Next() {
		var p Payment
		var vendorCode sql.NullString
		var invoiceNumber sql.NullInt32
		var vatRate, vatAmount, netAmount sql.NullFloat64
		var pCreatedBy sql.NullString

		if err := paymentRows.Scan(
			&p.ID, &p.PaymentType, &p.PaymentMethod, &vendorCode, &invoiceNumber,
			&p.Status, &p.Amount, &p.IsVATApplicable, &vatRate, &vatAmount, &netAmount,
			&p.CreatedAt, &p.UpdatedAt, &pCreatedBy,
		); err != nil {
			log.Printf("Failed to scan payment row for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if vendorCode.Valid {
			p.VendorCode = &vendorCode.String
		}
		if invoiceNumber.Valid {
			num := int(invoiceNumber.Int32)
			p.InvoiceNumber = &num
		}
		if vatRate.Valid {
			p.VATRate = &vatRate.Float64
		}
		if vatAmount.Valid {
			p.VATAmount = &vatAmount.Float64
		}
		if netAmount.Valid {
			p.NetAmount = &netAmount.Float64
		}
		if pCreatedBy.Valid {
			p.CreatedBy = &pCreatedBy.String
		}

		if p.Status == "completed" {
			amountPaid += p.Amount
			if p.CreatedAt.Before(booking.StartTime) {
				preStartAmountPaid += p.Amount
			}
		}
		booking.Payments = append(booking.Payments, p)
	}

	booking.TotalAmount = totalAmount
	booking.AmountPaid = amountPaid
	booking.AmountDue = totalAmount - amountPaid
	booking.DurationMinutes = durationMinutes

	populateDepositFields(&booking, depositRequired, preStartAmountPaid)

	discounts, err := fetchBookingDiscounts(r.Context(), bookingID)
	if err != nil {
		log.Printf("Failed to fetch discounts for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	booking.Discounts = discounts

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/bookings/{id}/calendar - returns standalone .ics file
func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	userID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || userID == "" {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	var bookingIDDB, userIDDB, status, notes, createdBy string
	var startTime, createdAt, updatedAt time.Time
	var durationMinutes int

	if err := db.Conn.QueryRow(r.Context(), `
		SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
		total_duration_minutes
		FROM bookings
		WHERE id = $1 AND user_id = $2
	`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch booking for calendar: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	rows, err := db.Conn.Query(r.Context(), `
		SELECT s.name, COALESCE(bs.override_price, s.price)
		FROM booking_services bs
		JOIN services s ON bs.service_id = s.id
		WHERE bs.booking_id = $1
		UNION ALL
		SELECT cs.name, COALESCE(bcs.override_price, cs.price)
		FROM booking_custom_services bcs
		JOIN custom_services cs ON bcs.custom_service_id = cs.id
		WHERE bcs.booking_id = $1
	`, bookingID)
	if err != nil {
		log.Printf("Failed to fetch services: %v", err)
	}
	defer rows.Close()

	var services []string
	var totalPrice float64
	for rows.Next() {
		var name string
		var price float64
		_ = rows.Scan(&name, &price)
		services = append(services, name)
		totalPrice += price
	}
	if err := rows.Err(); err != nil {
		log.Printf("Row iteration error in GetBookingICalHandler: %v", err)
	}

	serviceList := strings.Join(services, ", ")
	endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
	icalContent := generateICS(serviceList, startTime, endTime, status, notes, totalPrice)

	w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"booking-%s.ics\"", bookingID))
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(icalContent))
}

func sanitizeICS(s string) string {
	s = strings.ReplaceAll(s, "\\", "\\\\")
	s = strings.ReplaceAll(s, ";", "\\;")
	s = strings.ReplaceAll(s, ":", "\\:")
	s = strings.ReplaceAll(s, "\n", "\\n")
	s = strings.ReplaceAll(s, "\r", "")
	return s
}

func generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
	uid := fmt.Sprintf("booking-%d@crussell.com", clock.Now().UnixNano())
	dtstamp := clock.Now().UTC().Format("20060102T150405Z")
	dtstart := start.UTC().Format("20060102T150405Z")
	dtend := end.UTC().Format("20060102T150405Z")

	sanitizedServiceList := sanitizeICS(serviceList)
	sanitizedStatus := sanitizeICS(status)
	sanitizedNotes := sanitizeICS(notes)

	summary := "Crussell Appointment"
	if sanitizedServiceList != "" {
		summary = "Crussell: " + sanitizedServiceList
	}

	description := fmt.Sprintf("Status: %s\\nServices: %s\\nPrice: £%.2f", sanitizedStatus, sanitizedServiceList, price)
	if sanitizedNotes != "" {
		description += "\\nNotes: " + sanitizedNotes
	}

	return fmt.Sprintf(`BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Crussell//Booking//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:%s
DTSTAMP:%s
DTSTART:%s
DTEND:%s
SUMMARY:%s
DESCRIPTION:%s
STATUS:%s
END:VEVENT
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, sanitizedStatus)
}

// OverlappingBooking represents a booking that overlaps with another
type OverlappingBooking struct {
	ID        string       `json:"id"`
	StartTime time.Time    `json:"start_time"`
	Duration  int          `json:"duration_minutes"`
	Status    string       `json:"status"`
	CreatedAt time.Time    `json:"created_at"`
	User      *UserSummary `json:"user,omitempty"`
	Services  []string     `json:"services,omitempty"`
}

// OverlappingBookingsResponse is the response for the overlapping bookings endpoint
type OverlappingBookingsResponse struct {
	Bookings []OverlappingBooking `json:"bookings"`
}

// GET /api/admin/bookings/overlapping?start=ISO&end=ISO
// Returns all bookings that overlap with the proposed time range
func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request) {
	startStr := r.URL.Query().Get("start")
	endStr := r.URL.Query().Get("end")
	if startStr == "" || endStr == "" {
		http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
		return
	}

	startTime, err := time.Parse(time.RFC3339, startStr)
	if err != nil {
		http.Error(w, "invalid start format, expected RFC3339", http.StatusBadRequest)
		return
	}
	endTime, err := time.Parse(time.RFC3339, endStr)
	if err != nil {
		http.Error(w, "invalid end format, expected RFC3339", http.StatusBadRequest)
		return
	}
	if !endTime.After(startTime) {
		http.Error(w, "end must be after start", http.StatusBadRequest)
		return
	}

	rows, err := db.Conn.Query(r.Context(), `
		SELECT
			b.id,
			b.start_time,
			b.status,
			b.created_at,
			b.total_duration_minutes as duration,
			u.id as user_id,
			u.fn,
			u.email,
			u.phone
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
			AND b.start_time < $2
			AND b.end_time > $1
		GROUP BY b.id, b.start_time, b.status, b.created_at, u.id, u.fn, u.email, u.phone
		ORDER BY b.start_time ASC
	`, startTime, endTime)
	if err != nil {
		log.Printf("Failed to query overlapping bookings: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []OverlappingBooking
	for rows.Next() {
		var ob OverlappingBooking
		ob.User = &UserSummary{}
		if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
			log.Printf("Failed to scan overlapping booking: %v", err)
			continue
		}

		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT name FROM (
				SELECT s.name
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = $1
				UNION ALL
				SELECT cs.name
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = $1
			) sub ORDER BY name
		`, ob.ID)
		if err == nil {
			for serviceRows.Next() {
				var name string
				if err := serviceRows.Scan(&name); err == nil {
					ob.Services = append(ob.Services, name)
				}
			}
			serviceRows.Close()
		}

		bookings = append(bookings, ob)
	}

	if bookings == nil {
		bookings = []OverlappingBooking{}
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
		log.Printf("Failed to encode overlapping bookings response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings/{id}/overlapping
// Returns all bookings that overlap with the specified booking
func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	// Get the booking's start time and duration
	var startTime time.Time
	var durationMinutes int
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT b.start_time, b.total_duration_minutes
		FROM bookings b
		WHERE b.id = $1
	`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)

	// Find overlapping bookings (excluding the current booking and cancelled/completed ones)
	rows, err := db.Conn.Query(r.Context(), `
		SELECT 
			b.id, 
			b.start_time, 
			b.status, 
			b.created_at,
			b.total_duration_minutes as duration,
			u.fn,
			u.email
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.id != $1
			AND b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
			AND b.start_time < $3
			AND b.end_time > $2
		GROUP BY b.id, b.start_time, b.status, b.created_at, u.fn, u.email
		ORDER BY b.created_at ASC
	`, bookingID, startTime, endTime)
	if err != nil {
		log.Printf("Failed to query overlapping bookings: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []OverlappingBooking
	for rows.Next() {
		var ob OverlappingBooking
		ob.User = &UserSummary{}
		if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.FullName, &ob.User.Email); err != nil {
			log.Printf("Failed to scan overlapping booking: %v", err)
			continue
		}

		// Get services for this booking
		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT name FROM (
				SELECT s.name
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = $1
				UNION ALL
				SELECT cs.name
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = $1
			) sub ORDER BY name
		`, ob.ID)
		if err == nil {
			for serviceRows.Next() {
				var name string
				if err := serviceRows.Scan(&name); err == nil {
					ob.Services = append(ob.Services, name)
				}
			}
			serviceRows.Close()
		}

		bookings = append(bookings, ob)
	}

	if bookings == nil {
		bookings = []OverlappingBooking{}
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
		log.Printf("Failed to encode overlapping bookings response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings/by-date-range?start=YYYY-MM-DD&end=YYYY-MM-DD
func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
	startStr := r.URL.Query().Get("start")
	endStr := r.URL.Query().Get("end")
	if startStr == "" || endStr == "" {
		http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
		return
	}

	startTime, err := time.Parse("2006-01-02", startStr)
	if err != nil {
		http.Error(w, "invalid start format, expected YYYY-MM-DD", http.StatusBadRequest)
		return
	}
	endTime, err := time.Parse("2006-01-02", endStr)
	if err != nil {
		http.Error(w, "invalid end format, expected YYYY-MM-DD", http.StatusBadRequest)
		return
	}

	// Convert date-only params to London-aligned boundaries so bookings
	// at BST midnight (23:xx UTC = 00:xx BST next day) are correctly included.
	startLondon := startTime.In(londonLocation)
	startTime = time.Date(startLondon.Year(), startLondon.Month(), startLondon.Day(), 0, 0, 0, 0, londonLocation).UTC()
	endLondon := endTime.In(londonLocation)
	endOfDay := time.Date(endLondon.Year(), endLondon.Month(), endLondon.Day(), 23, 59, 59, 999999999, londonLocation).UTC()

	rows, err := db.Conn.Query(r.Context(), `
		SELECT
			b.id,
			b.start_time,
			b.status,
			b.notes,
			b.created_at,
			b.total_duration_minutes as duration,
			u.id as user_id,
			u.fn,
			u.email,
			u.phone
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
			AND b.start_time >= $1
			AND b.start_time < $2
		GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone
		ORDER BY b.start_time ASC
	`, startTime, endOfDay)
	if err != nil {
		log.Printf("Failed to query bookings by date range: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []OverlappingBooking
	for rows.Next() {
		var ob OverlappingBooking
		ob.User = &UserSummary{}
		var notes sql.NullString
		if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &notes, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
			log.Printf("Failed to scan booking: %v", err)
			continue
		}

		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT name FROM (
				SELECT s.name
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = $1
				UNION ALL
				SELECT cs.name
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = $1
			) sub ORDER BY name
		`, ob.ID)
		if err == nil {
			for serviceRows.Next() {
				var name string
				if err := serviceRows.Scan(&name); err == nil {
					ob.Services = append(ob.Services, name)
				}
			}
			serviceRows.Close()
		}

		bookings = append(bookings, ob)
	}

	if bookings == nil {
		bookings = []OverlappingBooking{}
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
		log.Printf("Failed to encode bookings response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// GET /api/admin/bookings/by-created-range?start=ISO&end=ISO
// Returns all bookings created within a time range (filters by created_at)
func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
	startStr := r.URL.Query().Get("start")
	endStr := r.URL.Query().Get("end")
	if startStr == "" || endStr == "" {
		http.Error(w, "start and end query parameters are required", http.StatusBadRequest)
		return
	}

	startTime, err := time.Parse(time.RFC3339, startStr)
	if err != nil {
		startTime, err = time.Parse("2006-01-02T15:04:05Z", startStr)
		if err != nil {
			http.Error(w, "invalid start format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest)
			return
		}
	}
	endTime, err := time.Parse(time.RFC3339, endStr)
	if err != nil {
		endTime, err = time.Parse("2006-01-02T15:04:05Z", endStr)
		if err != nil {
			http.Error(w, "invalid end format, expected RFC3339 or YYYY-MM-DDTHH:MM:SSZ", http.StatusBadRequest)
			return
		}
	}

	rows, err := db.Conn.Query(r.Context(), `
		SELECT
			b.id,
			b.start_time,
			b.status,
			b.notes,
			b.created_at,
			b.total_duration_minutes as duration,
			u.id as user_id,
			u.fn,
			u.email,
			u.phone
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.created_at >= $1
			AND b.created_at < $2
		GROUP BY b.id, b.start_time, b.status, b.notes, b.created_at, u.id, u.fn, u.email, u.phone
		ORDER BY b.created_at ASC
	`, startTime, endTime)
	if err != nil {
		log.Printf("Failed to query bookings by created range: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var bookings []OverlappingBooking
	for rows.Next() {
		var ob OverlappingBooking
		ob.User = &UserSummary{}
		var notes sql.NullString
		if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &notes, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
			log.Printf("Failed to scan booking: %v", err)
			continue
		}

		serviceRows, err := db.Conn.Query(r.Context(), `
			SELECT name FROM (
				SELECT s.name
				FROM booking_services bs
				JOIN services s ON bs.service_id = s.id
				WHERE bs.booking_id = $1
				UNION ALL
				SELECT cs.name
				FROM booking_custom_services bcs
				JOIN custom_services cs ON bcs.custom_service_id = cs.id
				WHERE bcs.booking_id = $1
			) sub ORDER BY name
		`, ob.ID)
		if err == nil {
			for serviceRows.Next() {
				var name string
				if err := serviceRows.Scan(&name); err == nil {
					ob.Services = append(ob.Services, name)
				}
			}
			serviceRows.Close()
		}

		bookings = append(bookings, ob)
	}

	if bookings == nil {
		bookings = []OverlappingBooking{}
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: bookings}); err != nil {
		log.Printf("Failed to encode bookings response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// PUT /api/admin/bookings/{id}/reschedule
func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
	bookingID := chi.URLParam(r, "id")
	if bookingID == "" || !validators.IsValidID(bookingID) {
		http.Error(w, "Booking not found", http.StatusNotFound)
		return
	}

	var req struct {
		StartTime     time.Time `json:"start_time"`
		ForgiveFees   *bool     `json:"forgive_fees,omitempty"`
		ForgiveNoShow *bool     `json:"forgive_noshow,omitempty"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Failed to decode request: %v", err)
		http.Error(w, "Invalid request", http.StatusBadRequest)
		return
	}
	if err := validators.Validate.Struct(&req); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}

	if req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if req.StartTime.Before(clock.Now()) {
		http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
		return
	}

	adminID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || adminID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	var currentStatus string
	var bookingUserID string
	var startTime time.Time
	if err := db.Conn.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &bookingUserID, &startTime); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if currentStatus == "completed" || currentStatus == "client_cancelled" || currentStatus == "we_cancelled" {
		http.Error(w, "Cannot reschedule a completed or cancelled booking", http.StatusForbidden)
		return
	}

	// All write operations wrapped in a transaction for atomicity.
	tx, txErr := db.Conn.Begin(r.Context())
	if txErr != nil {
		log.Printf("Failed to begin transaction for reschedule: %v", txErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer func() {
    if err := tx.Rollback(r.Context()); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow
	if forgiveNoShow && bookingUserID != "" {
		if _, err := tx.Exec(r.Context(), `
			INSERT INTO forgiven_no_shows (booking_id, forgiven_by)
			VALUES ($1, $2)
			ON CONFLICT (booking_id) DO NOTHING
		`, bookingID, adminID); err != nil {
			log.Printf("Failed to insert forgiven_no_show for rescheduled booking %s: %v", bookingID, err)
		}
	}

	forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
	if forgiveFees {
		log.Printf("[AUDIT] Admin %s rescheduled booking %s with fee forgiveness (start_time: %s)", adminID, bookingID, req.StartTime.Format(time.RFC3339))
	}

	var durationMinutes int
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT total_duration_minutes FROM bookings WHERE id = $1
	`, bookingID).Scan(&durationMinutes); err != nil {
		log.Printf("Failed to get booking duration %s: %v", bookingID, err)
		durationMinutes = 60
	}

	newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)

	blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime, &adminID)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	} else if blockerOverlap {
		http.Error(w, "Cannot reschedule to this time - slot is blocked", http.StatusConflict)
		return
	}

	localStart := req.StartTime.In(londonLocation)
	weekday := int((localStart.Weekday() + 6) % 7)
	bookingTime := localStart.Format("15:04:05")
	daysToMonday := int(localStart.Weekday())
	if daysToMonday == 0 {
		daysToMonday = 7
	}
	tm := localStart.AddDate(0, 0, -daysToMonday+1)
	// Use UTC midnight so the time.Time has Location=UTC at the London calendar date.
	// tm has Location=London (from .In(londonLocation) above), so tm.Year/Month/Day()
	// return London calendar values. Creating a UTC midnight of those values produces
	// a Location=UTC time at the correct London calendar Monday. pgx's DATE codec
	// extracts the calendar date from the time's own location — so this maps correctly
	// to ega.week_start (DATE column), regardless of BST/GMT.
	weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)

	var isClosed bool
	if err := db.Conn.QueryRow(r.Context(), `
		SELECT EXISTS (
			SELECT 1 FROM exceptional_working_hours ewh
			JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
			WHERE ega.week_start = $1
			  AND ewh.weekday = $2
			  AND ewh.is_open = false
			  AND ewh.start_time <= $3
			  AND ewh.end_time >= $3
		)
	`, weekStart, weekday, bookingTime).Scan(&isClosed); err != nil {
		log.Printf("Failed to check exceptional hours: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if isClosed {
		http.Error(w, "Cannot reschedule to a closed day", http.StatusBadRequest)
		return
	}
	if _, evictErr := EvictPendingReleaseOverlapping(r.Context(), tx, req.StartTime, newEndTime); evictErr != nil {
		log.Printf("Failed to evict pending_release bookings on reschedule: %v", evictErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	var overlapCount int
	if err := tx.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings
		WHERE id != $1
		AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
		AND start_time < $3
		AND end_time > $2
	`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
		log.Printf("Failed to check overlap %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if overlapCount > 0 {
		http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		UPDATE bookings
		SET start_time = $1, updated_at = NOW()
		WHERE id = $2
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
	`, req.StartTime, bookingID).Scan(
		&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
		&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to reschedule booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if err := tx.Commit(r.Context()); err != nil {
		log.Printf("Failed to commit reschedule for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(booking); err != nil {
		log.Printf("Failed to encode booking response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// EvictedBooking holds the ID and user ID of a booking evicted from
// pending_release to deposit_lapsed.
type EvictedBooking struct {
	ID     string
	UserID string
}

// EvictPendingReleaseOverlapping evicts any pending_release bookings whose slot
// overlaps with [startTime, endTime). The PAYMENT_IN_FLIGHT guard prevents
// evicting a booking that a user is currently paying for (the 5-minute
// time_blocker window). Returns the list of evicted bookings (id + user_id)
// for any caller that needs to react (e.g. notify the affected user).
func EvictPendingReleaseOverlapping(ctx context.Context, tx pgx.Tx, startTime, endTime time.Time) ([]EvictedBooking, error) {
	rows, err := tx.Query(ctx, `
		UPDATE bookings SET status = 'deposit_lapsed', updated_at = NOW()
		WHERE status = 'pending_release'
		  AND start_time < $2
		  AND end_time > $1
		  AND NOT EXISTS (
			  SELECT 1 FROM time_blockers
			  WHERE description = 'PAYMENT_IN_FLIGHT:' || bookings.id
			    AND start_time + (duration_minutes * INTERVAL '1 minute') > NOW()
		  )
		RETURNING id, user_id
	`, startTime, endTime)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var evicted []EvictedBooking
	for rows.Next() {
		var e EvictedBooking
		if err := rows.Scan(&e.ID, &e.UserID); err != nil {
			return nil, err
		}
		evicted = append(evicted, e)
	}
	return evicted, rows.Err()
}

// TODO: notify the evicted user that their slot was released
// (e.g. email/SMS — not yet implemented).
