package bookings

import (
	"crussell/db"
	"crussell/handlers/notifications"
	"crussell/handlers/scheduling"
	"crussell/internal/dav"
	"crussell/internal/validators"
	"crussell/mw"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"

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

var londonLocation = func() *time.Location {
	loc, err := time.LoadLocation("Europe/London")
	if err != nil {
		panic("Europe/London timezone not available")
	}
	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"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
	CreatedBy *string   `json:"created_by,omitempty"`

	// 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"`

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

// 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 * 0.20
	// A deposit is considered paid when pre-start payments cover the deposit amount.
	// We only declare it paid when a deposit was actually required, so that
	// bookings with no deposit obligation don't incorrectly show DepositPaid: true.
	b.DepositPaid = depositRequired && preStartAmountPaid >= b.DepositAmount
	deadline := b.StartTime.Add(-24 * time.Hour).Format(time.RFC3339)
	b.DepositDeadline = &deadline
}

// 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"`
	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 re-schedule no_show"`
}

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

// 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"`
	ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
	Notes            *string           `json:"notes,omitempty"`
}

// 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 re-schedule no_show"`
	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"`
}

// 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"`
}

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"`
}

// 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"`
}

// 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
	}
	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 <= 100 {
			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)

	// deposit_required is read from the bookings row (snapshotted at creation).
	// pre_start_amount_paid sums completed payments created before start_time.
	baseQuery := `
        SELECT
            b.id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
            (SELECT COALESCE(SUM(CASE
                WHEN bs.override_price IS NOT NULL THEN bs.override_price
                ELSE s.price
            END), 0)
            FROM booking_services bs
            JOIN services s ON bs.service_id = s.id
            WHERE bs.booking_id = b.id) AS total_amount,
            (SELECT COALESCE(SUM(amount), 0)
            FROM payments
            WHERE booking_id = b.id AND status = 'completed') AS amount_paid,
            (SELECT COALESCE(SUM(CASE
                WHEN bs.override_duration_minutes IS NOT NULL THEN bs.override_duration_minutes
                ELSE s.duration_minutes
            END), 0)
            FROM booking_services bs
            JOIN services s ON bs.service_id = s.id
            WHERE bs.booking_id = b.id) AS duration_minutes,
            b.deposit_required,
            (SELECT COALESCE(SUM(amount), 0)
            FROM payments
            WHERE booking_id = b.id AND status = 'completed' AND created_at < b.start_time) AS pre_start_amount_paid
        FROM bookings b
        WHERE b.user_id = $1
    `

	countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
	var args, countArgs []interface{}
	args = append(args, userID)
	countArgs = append(countArgs, userID)
	paramCount := 2

	if req.Status != nil {
		baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount)
		countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
		args = append(args, *req.Status)
		countArgs = append(countArgs, *req.Status)
		paramCount++
	}
	if req.StartDate != nil {
		baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
		countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount)
		startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
		if err != nil {
			http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
		args = append(args, startTime)
		countArgs = append(countArgs, startTime)
		paramCount++
	}
	if req.EndDate != nil {
		baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount)
		countQuery += fmt.Sprintf(" AND 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
		}
		endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
		args = append(args, endTime)
		countArgs = append(countArgs, endTime)
		paramCount++
	}

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

	var total int
	if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
		log.Printf("Failed to get booking count for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	rows, err := db.DB.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
	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
		}
		if createdBy.Valid {
			b.CreatedBy = &createdBy.String
		}
		b.TotalAmount = totalAmount
		b.AmountPaid = amountPaid
		b.AmountDue = totalAmount - amountPaid
		b.DurationMinutes = durationMinutes
		populateDepositFields(&b, depositRequired, preStartAmountPaid)
		bookings = append(bookings, b)
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings: bookings,
		Page:     req.Page,
		PerPage:  req.PerPage,
		Total:    total,
	}); 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
                bs.booking_id,
                SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
                SUM(COALESCE(bs.override_price, s.price)) AS total_amount
            FROM booking_services bs
            LEFT JOIN services s ON bs.service_id = s.id
            GROUP BY bs.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,
            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,
            (SELECT COALESCE(SUM(p2.amount), 0)
             FROM payments p2
             WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
        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
    `

	countQuery := `SELECT COUNT(*) FROM bookings b`
	var args []interface{}
	paramCount := 1
	whereAdded := false

	addWhereClause := func(condition string) {
		if whereAdded {
			baseQuery += " AND " + condition
			countQuery += " AND " + condition
		} else {
			baseQuery += " WHERE " + condition
			countQuery += " WHERE " + condition
			whereAdded = true
		}
	}

	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
		}
		args = append(args, startTime)
		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
		}
		args = append(args, endTime.Add(23*time.Hour+59*time.Minute+59*time.Second))
		paramCount++
	}

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

	countArgs := args
	if req.PerPage > 0 {
		countArgs = args[:len(args)-2]
	}

	var total int
	if err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total); err != nil {
		log.Printf("Failed to get total booking count: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

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

	rows, err := db.DB.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

		if err := rows.Scan(
			&b.ID, &b.StartTime, &b.Status, &userFullName,
			&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
			&depositRequired, &preStartAmountPaid,
		); 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)
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.DB.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)
			ORDER BY bs.booking_id, s.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
			}
		}
	}

	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,
	}); 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()
	page, perPage := 1, 5
	if pageStr := query.Get("page"); pageStr != "" {
		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
			page = p
		}
	}
	if perPageStr := query.Get("per_page"); perPageStr != "" {
		if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
			perPage = pp
		}
	}

	var total int
	if err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE 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.DB.Query(r.Context(), `
		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
		ORDER BY b.start_time DESC
		LIMIT $2 OFFSET $3
	`, userID, perPage, (page-1)*perPage)
	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
	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
		}

		serviceRows, err := db.DB.Query(r.Context(), `
			SELECT
				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
		`, b.ID)
		if err != nil {
			log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
			continue
		}

		var totalAmount float64
		for serviceRows.Next() {
			var svc BookingService
			var price float64
			var dur int
			if err := serviceRows.Scan(&svc.ServiceName, &price, &dur); err != nil {
				log.Printf("Failed to scan service: %v", err)
				continue
			}
			svc.Price = &price
			svc.DurationMinutes = &dur
			totalAmount += price
			b.Services = append(b.Services, svc)
		}
		serviceRows.Close()

		// Fetch both all-time and pre-start paid amounts in one query
		var amountPaid, preStartAmountPaid float64
		db.DB.QueryRow(r.Context(), `
			SELECT
				COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0),
				COALESCE(SUM(amount) FILTER (WHERE status = 'completed' AND created_at < $2), 0)
			FROM payments
			WHERE booking_id = $1
		`, b.ID, b.StartTime).Scan(&amountPaid, &preStartAmountPaid)

		b.TotalAmount = totalAmount
		b.AmountPaid = amountPaid
		b.AmountDue = totalAmount - amountPaid
		populateDepositFields(&b, depositRequired, preStartAmountPaid)
		bookings = append(bookings, b)
	}

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

	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,
		Page:       page,
		PerPage:    perPage,
		TotalPages: totalPages,
	}); err != nil {
		log.Printf("Failed to encode bookings response: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
	}
}

// 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

	err := db.DB.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 {
		if errors.Is(err, sql.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.DB.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.DB.Query(r.Context(), `
		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
		ORDER BY s.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.DB.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)
	}
}

// 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 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, fmt.Sprintf("Invalid service ID: %s", sid), 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.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
		if errors.Is(err, sql.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]
	}

	var newTotalDuration int
	for _, serviceID := range req.ServiceIDs {
		var durationMinutes int
		if ov, exists := overrideMap[serviceID]; exists && ov.OverrideDurationMinutes != nil {
			durationMinutes = *ov.OverrideDurationMinutes
		} else {
			err := db.DB.QueryRow(r.Context(), "SELECT duration_minutes FROM services WHERE id = $1", serviceID).Scan(&durationMinutes)
			if err != nil {
				if errors.Is(err, sql.ErrNoRows) {
					http.Error(w, fmt.Sprintf("Service not found: %s", serviceID), http.StatusBadRequest)
					return
				}
				log.Printf("Failed to fetch service %s: %v", serviceID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}
		newTotalDuration += durationMinutes
	}

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

	var nextBookingStart *time.Time
	err := db.DB.QueryRow(r.Context(), `
		SELECT start_time FROM bookings
		WHERE start_time > $1
		AND status IN ('confirmed', 'pending', 'in_progress')
		ORDER BY start_time ASC
		LIMIT 1
	`, startTime).Scan(&nextBookingStart)
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		log.Printf("Failed to check next booking: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if nextBookingStart != nil && newEndTime.After(*nextBookingStart) {
		http.Error(w, fmt.Sprintf("New booking duration overlaps with next appointment starting at %s", nextBookingStart.Format(time.RFC3339)), http.StatusConflict)
		return
	}

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

	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
	}

	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
		}
	}

	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.DB.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.DB.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.DB.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
		ORDER BY s.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.DB.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
	}

	page, perPage := 1, 10
	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
			page = p
		}
	}
	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
                bs.booking_id,
                SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)) AS total_duration,
                SUM(COALESCE(bs.override_price, s.price)) AS total_amount
            FROM booking_services bs
            LEFT JOIN services s ON bs.service_id = s.id
            GROUP BY bs.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,
            (SELECT COALESCE(SUM(p2.amount), 0)
             FROM payments p2
             WHERE p2.booking_id = b.id AND p2.status = 'completed' AND p2.created_at < b.start_time) AS pre_start_amount_paid
        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
        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 '\'
            )
        ORDER BY b.start_time DESC
        LIMIT $2 OFFSET $3
    `

	countQuery := `
        SELECT COUNT(DISTINCT b.id)
        FROM bookings b
        LEFT JOIN users u ON b.user_id = u.id
        LEFT JOIN booking_services bs ON b.id = bs.booking_id
        LEFT JOIN services s ON bs.service_id = s.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
            s.name ILIKE $1 ESCAPE '\'
    `

	var total int
	if err := db.DB.QueryRow(r.Context(), countQuery, searchPattern).Scan(&total); err != nil {
		log.Printf("Failed to get search count: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

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

	rows, err := db.DB.Query(r.Context(), searchQuery, searchPattern, perPage, (page-1)*perPage)
	if err != nil {
		log.Printf("Failed to search 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

		if err := rows.Scan(
			&b.ID, &b.StartTime, &b.Status, &userFullName,
			&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
			&depositRequired, &preStartAmountPaid,
		); 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)
	}

	if len(bookingIDs) > 0 {
		serviceRows, err := db.DB.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)
			ORDER BY bs.booking_id, s.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{}
			}
		}
	}

	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(BookingListResponse{
		Bookings:   bookings,
		Page:       page,
		PerPage:    perPage,
		Total:      total,
		TotalPages: totalPages,
	}); 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 existingID string
		err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
		if err == nil {
			// Booking already exists with this key — fetch and return it
			var existingBooking Booking
			existingBooking.User = &UserSummary{}
			err := db.DB.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
			`, existingID).Scan(
				&existingBooking.ID, &existingBooking.User.ID, &existingBooking.StartTime, &existingBooking.Status,
				&existingBooking.Notes, &existingBooking.CreatedAt, &existingBooking.UpdatedAt, &existingBooking.CreatedBy,
				&existingBooking.DepositRequired,
			)
			if err == nil {
				// Fetch services for the response
				rows, err := db.DB.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
				`, existingID)
				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
						}
						existingBooking.Services = append(existingBooking.Services, bs)
					}
				}

				// Get deposit info
				var depositRequired bool
				var preStartPaid float64
				db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
				db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
				populateDepositFields(&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.DB.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 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.DB.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.DB.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(time.Now().Add(1 * time.Hour)) {
		http.Error(w, "Bookings must be at least 1 hour in advance", http.StatusBadRequest)
		return
	}

	// Deposit users must book at least 24h in advance to allow time for deposit payment
	if !isGuest && depositsRequired > 0 && req.StartTime.Before(time.Now().Add(24*time.Hour)) {
		http.Error(w, "When deposits are required, bookings must be made at least 24 hours in advance to allow time for payment.", 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 {
		for _, serviceID := range req.ServiceIDs {
			var patchTestID string
			var noticeHours int
			err := db.DB.QueryRow(r.Context(), `
				SELECT id, notice_duration_hours
				FROM patch_tests
				WHERE $1 = ANY(service_ids)
			`, serviceID).Scan(&patchTestID, &noticeHours)

			if err == nil {
				var testedAt time.Time
				err = db.DB.QueryRow(r.Context(), `
					SELECT tested_at
					FROM user_patch_tests
					WHERE user_id = $1 AND patch_test_id = $2
				`, userID, patchTestID).Scan(&testedAt)
				if err != nil {
					http.Error(w, "Patch test required for this service. Please complete a patch test first.", http.StatusBadRequest)
					return
				}

				eligibleFrom := testedAt.Add(time.Duration(noticeHours) * time.Hour)
				if time.Now().Before(eligibleFrom) {
					hoursLeft := time.Until(eligibleFrom).Hours()
					http.Error(w, fmt.Sprintf("You must wait %.0f hours after your patch test before booking this service.", hoursLeft), http.StatusBadRequest)
					return
				}

				var expiryMonths int
				if err := db.DB.QueryRow(r.Context(), `SELECT expiry_months FROM patch_tests WHERE id = $1`, patchTestID).Scan(&expiryMonths); err == nil {
					if time.Now().After(testedAt.AddDate(0, expiryMonths, 0)) {
						http.Error(w, "Your patch test has expired. Please complete a new patch test.", http.StatusBadRequest)
						return
					}
				}
			}
		}
	}

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

	var svcDuration int
	if err := db.DB.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
	}

	weekday := int(req.StartTime.Weekday())
	var closeStr string
	if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text 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)
	closeTime, _ := time.Parse("15:04:05", closeStr)
	if endTime.Hour() > closeTime.Hour() || (endTime.Hour() == closeTime.Hour() && endTime.Minute() > closeTime.Minute()) {
		http.Error(w, "Cannot book this time - services would extend beyond closing hours", http.StatusBadRequest)
		return
	}

	var cnt int
	db.DB.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings WHERE status IN ('confirmed','in_progress','completed')
		AND start_time < $2
		AND start_time + (INTERVAL '1 minute' * (
			SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
			FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
		)) > $1
	`, req.StartTime, endTime).Scan(&cnt)
	if cnt > 0 {
		http.Error(w, "Cannot book this time - slot overlaps with an existing booking", http.StatusConflict)
		return
	}

	// Check for time blocker overlap
	blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, endTime)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	} else if blockerOverlap {
		http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
		return
	}

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

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

	// Delete any existing reservation for this user (max 1 per user)
	// Also matches anon reservations by start_time for users who register mid-flow
	_, _ = 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)

	// 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
	}

	for _, serviceID := range req.ServiceIDs {
		if _, err := tx.Exec(r.Context(), `INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)`, booking.ID, serviceID); 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 {
		london, _ := time.LoadLocation("Europe/London")
		now := time.Now().In(london)
		bookingDay := req.StartTime.In(london)
		if now.Year() == bookingDay.Year() && now.YearDay() == bookingDay.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.DB.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)
		return
	}
}

// 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 req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if req.StartTime.Before(time.Now()) {
		http.Error(w, "Start time cannot be in the past", http.StatusBadRequest)
		return
	}

	var currentStatus string
	if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); err != nil {
		if errors.Is(err, sql.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
	}

	var durationMinutes int
	if err := db.DB.QueryRow(r.Context(), `
		SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
		FROM booking_services bs
		JOIN services s ON bs.service_id = s.id
		WHERE bs.booking_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)
	var overlapCount int
	if err := db.DB.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings
		WHERE id != $1
		AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
		AND start_time < $3
		AND start_time + (INTERVAL '1 minute' * (
			SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
			FROM booking_services bs
			JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = bookings.id
		)) > $2
	`, bookingID, req.StartTime, newEndTime).Scan(&overlapCount); err != nil {
		log.Printf("Failed to check overlap %s: %v", bookingID, err)
	}
	if overlapCount > 0 {
		http.Error(w, "This time slot overlaps with an existing booking", http.StatusConflict)
		return
	}

	// Check for time blocker overlap
	blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEndTime)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	} else if blockerOverlap {
		http.Error(w, fmt.Sprintf("Cannot book this time - slot is blocked: %s", blockerDesc), http.StatusConflict)
		return
	}

	weekday := int(req.StartTime.Weekday())
	bookingTime := req.StartTime.Format("15:04:05")
	daysToMonday := weekday
	if daysToMonday == 0 {
		daysToMonday = 7
	}
	weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)

	var isClosed bool
	if err := db.DB.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)
	}
	if isClosed {
		http.Error(w, "Cannot book on a closed day", http.StatusBadRequest)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := db.DB.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, sql.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
	}

	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
	}
	allowed := map[string]bool{
		"pending": true, "confirmed": true, "in_progress": true,
		"completed": true, "client_cancelled": true, "we_cancelled": true,
		"re-schedule": true, "no_show": true,
	}
	if !allowed[req.Status] {
		http.Error(w, "Invalid status", http.StatusBadRequest)
		return
	}

	var booking Booking
	booking.User = &UserSummary{}
	if err := db.DB.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, sql.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" {
		rows, err := db.DB.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 {
			defer rows.Close()
			for rows.Next() {
				var patchTestID string
				if err := rows.Scan(&patchTestID); err != nil {
					log.Printf("Failed to scan patch test: %v", err)
					continue
				}
				if _, err := db.DB.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, patchTestID); err != nil {
					log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, patchTestID, err)
				}
			}
		}

		var bookingTotal float64
		if err := db.DB.QueryRow(r.Context(), `
			SELECT COALESCE(SUM(COALESCE(bs.override_price, s.price)), 0)
			FROM booking_services bs
			JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = $1
		`, bookingID).Scan(&bookingTotal); err != nil {
			log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
		}

		// Apply existing pending loyalty redemption (earned from previous 10 bookings)
		if bookingTotal > 0 {
			var redemptionID string
			if err := db.DB.QueryRow(r.Context(), `
				SELECT id FROM loyalty_redemptions
				WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
				ORDER BY redeemed_at ASC LIMIT 1
			`, booking.User.ID).Scan(&redemptionID); err == nil && redemptionID != "" {
				discountAmount := roundTo2(bookingTotal * 0.10)

				_, _ = db.DB.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, 'loyalty', $3, NULL, NULL, 10.00, $4, $5)
				`, bookingID, booking.User.ID, redemptionID, bookingTotal, discountAmount)

				_, _ = db.DB.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)

				_, _ = db.DB.Exec(r.Context(), `
					UPDATE loyalty_redemptions SET status = 'applied', applied_to_booking_id = $1, applied_at = NOW()
					WHERE id = $2
				`, bookingID, redemptionID)

				_, _ = db.DB.Exec(r.Context(), `
					UPDATE users SET loyalty_stamps = GREATEST(0, loyalty_stamps - 10) WHERE id = $1
				`, booking.User.ID)
			}
		}

		// Increment stamps (max 1 per day, only for paid bookings)
		if bookingTotal > 0 {
			if _, err := db.DB.Exec(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
				)
			`, booking.User.ID, bookingID); err != nil {
				log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
			}
		}

		// Create pending redemption when stamps reach 10
		var newStampCount int
		if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, booking.User.ID).Scan(&newStampCount); err == nil && newStampCount == 10 {
			_, err = db.DB.Exec(r.Context(), `
				INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
				VALUES ($1, 10, 'pending', NOW())
			`, booking.User.ID)
			if err != nil {
				log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
			}
		}

		if bookingTotal > 0 {
			var hasLoyaltyDiscount bool
			_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&hasLoyaltyDiscount)

			if !hasLoyaltyDiscount {
				var campaignID string
				var campaignPercent float64
				if err := db.DB.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)

					_, _ = db.DB.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)

					_, _ = db.DB.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)

					_, _ = db.DB.Exec(r.Context(), `
						UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
					`, campaignID)
				}
			}
		}

		if bookingTotal > 0 {
			var hasDiscount bool
			_ = db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)`, bookingID).Scan(&hasDiscount)

			if !hasDiscount {
				var userBookingCount int
				_ = db.DB.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
				_ = db.DB.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)
					_, _ = db.DB.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)
					_, _ = db.DB.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)
					_, _ = db.DB.Exec(r.Context(), `
						UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
					`, milestoneCampaignID)
				}

				if milestoneCampaignID == "" {
					var globalCount int
					_ = db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
					var globalCampaignID string
					var globalPercent float64
					_ = db.DB.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)
					`, globalCount).Scan(&globalCampaignID, &globalPercent)

					if globalCampaignID != "" {
						discountAmount := roundTo2(bookingTotal * globalPercent / 100)
						_, _ = db.DB.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)
						_, _ = db.DB.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)
						_, _ = db.DB.Exec(r.Context(), `
							UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
						`, globalCampaignID)
					}
				}

				if milestoneCampaignID == "" {
					var firstVisitDate time.Time
					_ = db.DB.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
					if !firstVisitDate.IsZero() {
						rows, err := db.DB.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 {
							defer rows.Close()
							for rows.Next() {
								var annID string
								var annPercent float64
								var annValue int
								var annUnit string
								if rows.Scan(&annID, &annPercent, &annValue, &annUnit) == nil {
									var matches bool
									elapsed := time.Since(firstVisitDate)
									switch annUnit {
									case "months":
										months := int(elapsed.Hours() / (30 * 24))
										matches = months >= annValue
									case "years":
										years := int(elapsed.Hours() / (365.25 * 24))
										matches = years >= annValue
									}
									if matches {
										discountAmount := roundTo2(bookingTotal * annPercent / 100)
										_, _ = db.DB.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, annID, annPercent, bookingTotal, discountAmount)
										_, _ = db.DB.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)
										_, _ = db.DB.Exec(r.Context(), `
											UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
										`, annID)
										break
									}
								}
							}
						}
					}
				}
			}
		}

		var paymentCount int
		db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM payments WHERE booking_id = $1`, bookingID).Scan(&paymentCount)
		if paymentCount > 0 {
			db.DB.Exec(r.Context(), `UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) WHERE id = $1`, booking.User.ID)
		}
		// TODO: When online payments are live, create 'deposit_paid' notification here for:
		//   - Online deposit payments (user pays deposit via Square)
		//   - Early/late balance payments made online by the user
		//   NOT for admin-recorded in-person payments — admin already knows about those.
	}

	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
	}

	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 bkStart time.Time
	if err := db.DB.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
	}

	var dur int
	db.DB.QueryRow(r.Context(), `
		SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
		FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
	`, bookingID).Scan(&dur)
	newEnd := bkStart.Add(time.Duration(dur) * time.Minute)

	var cnt int
	db.DB.QueryRow(r.Context(), `
		SELECT COUNT(*) FROM bookings WHERE id != $1 AND status IN ('confirmed','in_progress','completed')
		AND start_time < $3
		AND start_time + (INTERVAL '1 minute' * (
			SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes,s.duration_minutes)),60)
			FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id
		)) > $2
	`, bookingID, bkStart, newEnd).Scan(&cnt)
	if cnt > 0 {
		http.Error(w, "Cannot confirm - time slot overlaps with existing booking", http.StatusConflict)
		return
	}

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

	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, sql.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 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
		db.DB.QueryRow(r.Context(), `
			SELECT COALESCE(SUM(override_duration_minutes), (SELECT SUM(duration_minutes) FROM booking_services WHERE booking_id = $1))
			FROM booking_services WHERE booking_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)
	}
}

// POST /api/admin/bookings/{id}/cancel
func CancelBookingHandler(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
	}

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

	var booking Booking
	booking.User = &UserSummary{}
	if err := tx.QueryRow(r.Context(), `
		UPDATE bookings
		SET status = 'we_cancelled', updated_at = NOW()
		WHERE id = $1 AND status NOT IN ('completed', 'cancelled', 'client_cancelled', 'we_cancelled')
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
	`, 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, sql.ErrNoRows) {
			http.Error(w, "Booking not found or cannot be cancelled", http.StatusNotFound)
			return
		}
		log.Printf("Failed to cancel booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	tx.Exec(r.Context(), `
		INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('cancelled_booking', $1, $2)
	`, bookingID, booking.User.ID)

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

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

// 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 paymentCount int
	if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount); 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 paymentCount > 0 {
		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
		}
		allowed := map[string]bool{
			"client_cancelled": true, "we_cancelled": true, "re-schedule": true, "no_show": true,
		}
		if !allowed[req.Reason] {
			http.Error(w, "Invalid reason", http.StatusBadRequest)
			return
		}

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

		var originalStatus string
		if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil {
			if errors.Is(err, sql.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
		}

		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" {
			var startTime time.Time
			tx.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&startTime)
			noticeHours := startTime.Sub(time.Now()).Hours()

			if noticeHours < 24 {
				isForgiving := req.ForgiveNoShow != nil && *req.ForgiveNoShow

				if !isForgiving {
					tx.Exec(r.Context(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bookingID)
				} else {
					tx.Exec(r.Context(), "UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID)
				}
			}

			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
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]interface{}{
			"message": "Booking cancelled successfully",
			"id":      bookingID,
			"status":  req.Reason,
		})
		return
	}

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

	var originalStatus string
	if err := tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus); err != nil {
		if errors.Is(err, sql.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
	}

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

	if originalStatus != "pending" {
		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
		}
	}

	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]interface{}{
		"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.DB.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, sql.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.DB.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
        ORDER BY s.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.DB.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)

	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.DB.QueryRow(r.Context(), `
		SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
			COALESCE((SELECT SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes))
					  FROM booking_services bs JOIN services s ON bs.service_id = s.id
					  WHERE bs.booking_id = bookings.id), 60)
		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, sql.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.DB.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
	`, 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
	}

	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 generateICS(serviceList string, start, end time.Time, status, notes string, price float64) string {
	uid := fmt.Sprintf("booking-%d@crussell.com", time.Now().UnixNano())
	dtstamp := time.Now().UTC().Format("20060102T150405Z")
	dtstart := start.Format("20060102T150405")
	dtend := end.Format("20060102T150405")

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

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

	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, status)
}

// 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/{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.DB.QueryRow(r.Context(), `
		SELECT b.start_time, 
			COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
		FROM bookings b
		LEFT JOIN booking_services bs ON b.id = bs.booking_id
		LEFT JOIN services s ON bs.service_id = s.id
		WHERE b.id = $1
		GROUP BY b.start_time
	`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
		if errors.Is(err, sql.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.DB.Query(r.Context(), `
		SELECT 
			b.id, 
			b.start_time, 
			b.status, 
			b.created_at,
			COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60) as duration,
			u.fn,
			u.email
		FROM bookings b
		LEFT JOIN booking_services bs ON b.id = bs.booking_id
		LEFT JOIN services s ON bs.service_id = s.id
		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', 'no_deposit')
			AND b.start_time < $3
			AND b.start_time + (INTERVAL '1 minute' * (
				SELECT COALESCE(SUM(COALESCE(bs2.override_duration_minutes, s2.duration_minutes)), 60)
				FROM booking_services bs2
				JOIN services s2 ON bs2.service_id = s2.id
				WHERE bs2.booking_id = b.id
			)) > $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.DB.Query(r.Context(), `
			SELECT s.name
			FROM booking_services bs
			JOIN services s ON bs.service_id = s.id
			WHERE bs.booking_id = $1
			ORDER BY s.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)
	}
}
