package bookings

import (
	"context"
	"crussell/clock"
	"crussell/db"
	"crussell/handlers/notifications"
	"crussell/handlers/payments"
	"crussell/handlers/scheduling"
	"crussell/internal/validators"
	"crussell/mw"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/jackc/pgx/v5"
	"log"
	"log/slog"
	"net/http"
	"strings"
	"time"

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

// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
// The update is performed in a transaction with notification handling.
func UserCancelBookingHandler(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
	}

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

	// Get current status before updating
	var originalStatus string
	err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not cancellable", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking status %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	res, err := tx.Exec(r.Context(), `
		UPDATE bookings
		SET status = 'client_cancelled', updated_at = $1
		WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
	`, clock.Now(), bookingID, userID)
	if err != nil {
		log.Printf("Failed to cancel booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	rowsAffected := res.RowsAffected()
	if rowsAffected == 0 {
		http.Error(w, "Booking not cancellable", http.StatusNotFound)
		return
	}

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

	if _, err := tx.Exec(r.Context(), `
		DELETE FROM booking_edit_requests WHERE booking_id = $1
	`, bookingID); err != nil {
		log.Printf("ALERT: failed to delete edit requests: %v", err)
	}
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers WHERE description = $1
	`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
		log.Printf("ALERT: failed to delete time_blocker: %v", err)
	}
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
	`, bookingID); err != nil {
		log.Printf("ALERT: failed to delete edit_requested notification: %v", err)
	}

	// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
	if originalStatus != "pending" {
		notificationQuery := `
			INSERT INTO admin_notifications (reason, booking_id, user_id)
			VALUES ($1, $2, $3)
		`
		_, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID)
		if 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 {
		log.Printf("Failed to commit user cancel: %v, %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

type AdminCancelBookingRequest struct {
	ForgiveFees   *bool `json:"forgive_fees,omitempty"`
	ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
}

func AdminCancelBookingHandler(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 AdminCancelBookingRequest
	if r.Body != nil {
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			log.Printf("failed to decode admin cancel request body: %v", err)
		}
	}
	forgiveFees := req.ForgiveFees != nil && *req.ForgiveFees
	forgiveNoShow := req.ForgiveNoShow != nil && *req.ForgiveNoShow

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

	// Fetch payment info before the transaction (read-only, no side effects).
	var refundResult *payments.RefundCalculationResult
	var refundFailed bool
	paySvc := payments.NewPaymentService()
	payInfo, payErr := paySvc.GetBookingPaymentInfo(r.Context(), bookingID)
	if payErr != nil {
		log.Printf("Failed to get booking payment info for %s: %v", bookingID, payErr)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	totalAmount := payInfo.TotalAmount
	totalPaid := payInfo.TotalPaid
	calculatedRefund := totalPaid > 0 && !forgiveFees

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

	// Get current status and user ID — use FOR UPDATE to lock the row so
	// the refund and status change are atomic.
	var originalStatus string
	var bookingUserID string
	if err := tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1 FOR UPDATE", bookingID).Scan(&originalStatus, &bookingUserID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not cancellable", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking status %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	res, err := tx.Exec(r.Context(), `
		UPDATE bookings
		SET status = 'we_cancelled', updated_at = $1
		WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
	`, clock.Now(), bookingID)
	if err != nil {
		log.Printf("Failed to admin cancel booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	rowsAffected := res.RowsAffected()
	if rowsAffected == 0 {
		http.Error(w, "Booking not cancellable", http.StatusNotFound)
		return
	}

	// Status update succeeded — now process the refund in the SAME transaction
	// so that a commit failure rolls back both the status change and the refund.
	if forgiveFees && totalPaid > 0 {
		refundResult = &payments.RefundCalculationResult{
			TotalPrePaid:     totalPaid,
			RefundableAmount: totalPaid,
			KeptAmount:       0,
			Tier:             "admin_full_refund",
		}
	}
	if calculatedRefund {
		var calc *payments.RefundCalculationResult
		calc, err = payments.ProcessCancellationRefundTx(r.Context(), tx, bookingID, totalAmount, totalPaid, payInfo.StartTime, clock.Now(), "admin_cancelled", &adminID)
		if err == nil {
			refundResult = calc
		} else {
			refundFailed = true
			log.Printf("ALERT: AdminCancelBookingHandler — ProcessCancellationRefundTx failed for booking %s after status was updated to we_cancelled. Refund was NOT processed. The transaction WILL be committed (cancellation stands, no refund). Error: %v", bookingID, err)
		}
	}

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

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

	// Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress)
	if originalStatus != "pending" {
		notificationQuery := `
			INSERT INTO admin_notifications (reason, booking_id, user_id)
			SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1
		`
		_, err = tx.Exec(r.Context(), notificationQuery, bookingID)
		if err != nil {
			log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	// Clean up any pending edit requests for this booking.
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM booking_edit_requests WHERE booking_id = $1
	`, bookingID); err != nil {
		log.Printf("ALERT: failed to delete edit requests on admin cancel: %v", err)
	}
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers WHERE description = $1
	`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
		log.Printf("ALERT: failed to delete edit request time_blocker on admin cancel: %v", err)
	}
	if _, err := tx.Exec(r.Context(), `
		DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested'
	`, bookingID); err != nil {
		log.Printf("ALERT: failed to delete edit_requested notification on admin cancel: %v", err)
	}

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

	// Process pending Square refunds after the transaction commits successfully.
	// This ensures Square API calls only happen if the DB records persist.
	if calculatedRefund {
		payments.ProcessPendingSquareRefunds(r.Context(), bookingID, "admin_cancelled")
	}

	if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
		resp := map[string]any{
			"message": "Booking cancelled",
		}
		if refundResult != nil && refundResult.RefundableAmount > 0 {
			resp["refund_calculation"] = refundResult
		}
		if refundFailed {
			resp["refund_failed"] = true
			resp["warning"] = "Booking was cancelled but refund processing failed — please process refund manually or retry"
		}
		_ = json.NewEncoder(w).Encode(resp)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler.
func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) {
	r = r.Clone(r.Context())
	q := r.URL.Query()
	q.Set("status", "pending")
	r.URL.RawQuery = q.Encode()

	GetAllAdminBookingsHandler(w, r)
}

// AdminGetInProgressBookingHandler returns the booking that is currently in progress.
// It joins the bookings table with users to populate the UserSummary in the returned Booking.
func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
	var b Booking
	var userID, fullName string

	err := db.Conn.QueryRow(r.Context(), `
		SELECT
			b.id,
			b.start_time,
			b.status,
			b.notes,
			b.created_at,
			b.updated_at,
			b.created_by,
			u.id,
			u.fn
		FROM bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE b.status = 'in_progress'
		ORDER BY b.start_time
		LIMIT 1
	`).Scan(
		&b.ID,
		&b.StartTime,
		&b.Status,
		&b.Notes,
		&b.CreatedAt,
		&b.UpdatedAt,
		&b.CreatedBy,
		&userID,
		&fullName,
	)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "No in-progress booking found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to fetch in-progress booking: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Populate the UserSummary field
	b.User = &UserSummary{
		ID:        userID,
		FullName:  fullName,
		FirstName: "", // not available here
		LastName:  "", // not available here
	}

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

type AdminCreateBookingForUserRequest struct {
	UserID           string            `json:"user_id" validate:"required"`
	StartTime        time.Time         `json:"start_time" validate:"required"`
	ServiceIDs       []string          `json:"service_ids,omitempty"`
	ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
	CustomServiceIDs []string          `json:"custom_service_ids,omitempty"`
	CustomOverrides  []ServiceOverride `json:"custom_service_overrides,omitempty"`
	Notes            *string           `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	EnforceDeposits  *bool             `json:"enforce_deposits,omitempty"`
	OutOfHours       bool              `json:"out_of_hours"`
}

func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
	// Admin identity (creator)
	adminID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || adminID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

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

	// 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.Conn.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.Conn.QueryRow(r.Context(), `
				SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
				FROM bookings b WHERE b.id = $1
			`, 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.Conn.Query(r.Context(), `
					SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
					       s.name, s.description, s.price, s.duration_minutes
					FROM booking_services bs
					JOIN services s ON bs.service_id = s.id
					WHERE bs.booking_id = $1
					UNION ALL
					SELECT bcs.booking_id, bcs.custom_service_id, bcs.override_price, bcs.override_duration_minutes,
					       cs.name, cs.description, cs.price, cs.duration_minutes
					FROM booking_custom_services bcs
					JOIN custom_services cs ON bcs.custom_service_id = cs.id
					WHERE bcs.booking_id = $1
				`, 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
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = db.Conn.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
//nolint:errcheck // zero value is acceptable fallback on scan failure
				_ = db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
				populateDepositFields(&existingBooking, depositRequired, preStartPaid)

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

	// Basic validation
	if req.UserID == "" {
		http.Error(w, "User ID is required", http.StatusBadRequest)
		return
	}
	if req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
		http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
		return
	}

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

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

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

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

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

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

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

	// Enforce deposit checks if requested (default: true if not specified)
	enforceDeposits := true
	if req.EnforceDeposits != nil {
		enforceDeposits = *req.EnforceDeposits
	}

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

		// Check one-active-booking limit when deposits are outstanding
		if depositsRequired > 0 {
			var activeCount int
			if err := db.Conn.QueryRow(r.Context(), `
				SELECT COUNT(*) FROM bookings
				WHERE user_id = $1 AND status IN ('pending', 'confirmed')
			`, req.UserID).Scan(&activeCount); err != nil {
				log.Printf("Failed to check active bookings for user %s: %v", req.UserID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			if activeCount > 0 {
				http.Error(w, "User already has an active booking. Cannot create another until deposit requirements are cleared.", http.StatusConflict)
				return
			}
		}
	}

	// Validate overrides
	if req.UserID == "" {
		http.Error(w, "User ID is required", http.StatusBadRequest)
		return
	}
	if req.StartTime.IsZero() {
		http.Error(w, "Start time is required", http.StatusBadRequest)
		return
	}
	if len(req.ServiceIDs) == 0 && len(req.CustomServiceIDs) == 0 {
		http.Error(w, "At least one service or custom service is required", http.StatusBadRequest)
		return
	}

	// Validate overrides
	for _, override := range req.ServiceOverrides {
		if override.ServiceID == "" {
			http.Error(w, "Service ID is required for overrides", http.StatusBadRequest)
			return
		}
		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
		}
	}

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

	if !req.OutOfHours {
		// Check if there's an exceptional hours entry that makes this time unavailable
		var isClosed bool
		checkErr := db.Conn.QueryRow(r.Context(), `
			SELECT EXISTS (
				SELECT 1 FROM exceptional_working_hours ewh
				JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
				WHERE ega.week_start = $1
				  AND ewh.weekday = $2
				  AND ewh.is_open = false
				  AND ewh.start_time <= $3
				  AND ewh.end_time >= $3
			)
		`, weekStart, weekday, bookingTime).Scan(&isClosed)
		if checkErr != nil {
			log.Printf("Failed to check exceptional hours: %v", checkErr)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		if isClosed {
			http.Error(w, "Cannot book during holiday hours when the salon is closed", http.StatusConflict)
			return
		}
	}

	// Check for overlapping confirmed/in_progress/completed bookings
	allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
	var dur int
	err := db.Conn.QueryRow(r.Context(), `
		SELECT COALESCE(SUM(dur), 0) FROM (
			SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
			UNION ALL
			SELECT duration_minutes FROM custom_services WHERE id = ANY($1)
		) combined
	`, allIDs).Scan(&dur)
	if err != nil {
		log.Printf("Failed to calculate duration: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)

	// Check for time blocker overlap - admin can proceed with warning
	// Pass adminID as excludeUserID so the admin's own reservation doesn't self-block
	blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), req.StartTime, newEnd, &adminID)
	if err != nil {
		log.Printf("Failed to check time blocker overlap: %v", err)
	}

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

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

	// Check for overlapping bookings (inside transaction)
	overlapRows, err := tx.Query(r.Context(), `
		SELECT 1 FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND end_time > $1
		FOR UPDATE
	`, req.StartTime, newEnd)
	if err != nil {
		log.Printf("Failed to check overlap: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	var overlapCount int
	for overlapRows.Next() {
		overlapCount++
	}
	overlapRows.Close()
	if err := overlapRows.Err(); err != nil {
		log.Printf("Overlap row iteration error: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	if overlapCount > 0 {
		http.Error(w, "Cannot create booking - time slot overlaps with existing booking", http.StatusConflict)
		return
	}

	// Create booking directly as confirmed
	bookingQuery := `
		INSERT INTO bookings (
			user_id,
			start_time,
			status,
			notes,
			created_by,
			idempotency_key,
			out_of_hours
		)
		VALUES ($1, $2, 'confirmed', $3, $4, $5, $6)
		RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by, out_of_hours
	`

	var booking Booking
	booking.User = &UserSummary{}

	err = tx.QueryRow(
		r.Context(),
		bookingQuery,
		req.UserID,
		req.StartTime,
		req.Notes,
		adminID,
		sql.NullString{String: idempotencyKey, Valid: idempotencyKey != ""},
		req.OutOfHours,
	).Scan(
		&booking.ID,
		&booking.User.ID,
		&booking.StartTime,
		&booking.Status,
		&booking.Notes,
		&booking.CreatedAt,
		&booking.UpdatedAt,
		&booking.CreatedBy,
		&booking.OutOfHours,
	)

	if err != nil {
		log.Printf("Failed to create admin booking: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

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

	if len(req.CustomServiceIDs) > 0 {
		_, err = tx.Exec(r.Context(), `
			INSERT INTO booking_custom_services (booking_id, custom_service_id)
			SELECT $1, unnest($2::text[])
		`, booking.ID, req.CustomServiceIDs)
		if err != nil {
			log.Printf("Failed to insert custom booking services: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if _, err := tx.Exec(r.Context(), `
			UPDATE custom_services SET usage_count = usage_count + 1, last_used_at = NOW() WHERE id = ANY($1)
		`, req.CustomServiceIDs); err != nil {
			log.Printf("ALERT: failed to update custom service usage: %v", err)
		}
	}

	// Apply overrides (optional)
	if len(req.ServiceOverrides) > 0 {
		// Ensure overrides only reference services in this booking
		serviceCheckQuery := `
			SELECT COUNT(*) FROM booking_services
			WHERE booking_id = $1 AND service_id = ANY($2)
		`

		overrideServiceIDs := make([]string, len(req.ServiceOverrides))
		for i, o := range req.ServiceOverrides {
			overrideServiceIDs[i] = o.ServiceID
		}

		var count int
		err = tx.QueryRow(
			r.Context(),
			serviceCheckQuery,
			booking.ID,
			overrideServiceIDs,
		).Scan(&count)

		if err != nil {
			log.Printf("Failed to verify service overrides: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		if count != len(req.ServiceOverrides) {
			http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest)
			return
		}

		overrideUpdateQuery := `
			UPDATE booking_services
			SET override_price = $1,
			    override_duration_minutes = $2
			WHERE booking_id = $3 AND service_id = $4
		`

		for _, override := range req.ServiceOverrides {
			_, err := tx.Exec(
				r.Context(),
				overrideUpdateQuery,
				override.OverridePrice,
				override.OverrideDurationMinutes,
				booking.ID,
				override.ServiceID,
			)
			if err != nil {
				log.Printf(
					"Failed to apply override (booking %s, service %s): %v",
					booking.ID,
					override.ServiceID,
					err,
				)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}
	}

	if len(req.CustomOverrides) > 0 {
		customOverrideServiceIDs := make([]string, len(req.CustomOverrides))
		for i, o := range req.CustomOverrides {
			customOverrideServiceIDs[i] = o.ServiceID
		}
		var customCount int
		err = tx.QueryRow(r.Context(), `
			SELECT COUNT(*) FROM booking_custom_services
			WHERE booking_id = $1 AND custom_service_id = ANY($2)
		`, booking.ID, customOverrideServiceIDs).Scan(&customCount)
		if err != nil {
			log.Printf("Failed to verify custom service overrides: %v", err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if customCount != len(req.CustomOverrides) {
			http.Error(w, "One or more custom service overrides do not belong to this booking", http.StatusBadRequest)
			return
		}

		customOverrideUpdateQuery := `
			UPDATE booking_custom_services
			SET override_price = $1,
			    override_duration_minutes = $2
			WHERE booking_id = $3 AND custom_service_id = $4
		`

		for _, override := range req.CustomOverrides {
			_, err := tx.Exec(
				r.Context(),
				customOverrideUpdateQuery,
				override.OverridePrice,
				override.OverrideDurationMinutes,
				booking.ID,
				override.ServiceID,
			)
			if err != nil {
				log.Printf(
					"Failed to apply custom service override (booking %s, custom service %s): %v",
					booking.ID,
					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 admin booking creation: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	// Build response with warnings if any

	warnings := []string{}

	if blockerOverlap {

		warnings = append(warnings, fmt.Sprintf("Warning: This booking overlaps with a time blocker: %s", blockerDesc))

	}

	response := map[string]any{

		"booking": booking,

		"warnings": warnings,
	}

	w.Header().Set("Content-Type", "application/json")

	w.WriteHeader(http.StatusCreated)

	if err := json.NewEncoder(w).Encode(response); err != nil {

		log.Printf("Failed to encode response: %v", err)

		http.Error(w, "Internal server error", http.StatusInternalServerError)

	}

}

// =============================================================================
// Booking Edit Request Handlers
// =============================================================================

// BookingEditRequest represents a user's request to edit a booking
type BookingEditRequest struct {
	ID           string     `json:"id"`
	BookingID    string     `json:"booking_id"`
	RequestedBy  string     `json:"requested_by"`
	NewStartTime *time.Time `json:"new_start_time,omitempty"`
	NewServices  []string   `json:"new_services"`
	Notes        *string    `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	HasOverrides bool       `json:"has_overrides"`
	UpdatedAt    time.Time  `json:"updated_at"`
	// Joined fields
	Booking *Booking     `json:"booking,omitempty"`
	User    *UserSummary `json:"user,omitempty"`
}

// Enriched response types for edit request detail views
type EditServiceDetail struct {
	ID              string  `json:"id"`
	Name            string  `json:"name"`
	Price           float64 `json:"price"`
	DurationMinutes int     `json:"duration_minutes"`
}

type EditSnapshot struct {
	StartTime *time.Time          `json:"start_time"`
	EndTime   *time.Time          `json:"end_time"`
	Services  []EditServiceDetail `json:"services"`
	Notes     *string             `json:"notes" validate:"omitempty,max=1000000"`
}

type EditUserSummary struct {
	ID                string  `json:"id"`
	FullName          string  `json:"full_name"`
	Email             *string `json:"email,omitempty"`
	Phone             *string `json:"phone,omitempty"`
	PreviousFirstName *string `json:"previous_first_name,omitempty"`
	PreviousLastName  *string `json:"previous_last_name,omitempty"`
}

type EnrichedEditRequest struct {
	ID          string           `json:"id"`
	BookingID   string           `json:"booking_id"`
	RequestedBy string           `json:"requested_by"`
	RequestedAt time.Time        `json:"requested_at"`
	Notes       *string          `json:"notes" validate:"omitempty,max=1000000"`
	Original    *EditSnapshot    `json:"original"`
	Proposed    *EditSnapshot    `json:"proposed"`
	User        *EditUserSummary `json:"user,omitempty"`
}

// buildEnrichedEditRequest builds a full enriched response from a pending edit request.
// It queries the database for original booking details, services, and user info.
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
	var bStartTime time.Time
	var bNotes *string
	err := db.Conn.QueryRow(ctx, `
		SELECT start_time, notes FROM bookings WHERE id = $1
	`, editReq.BookingID).Scan(&bStartTime, &bNotes)
	if err != nil {
		return nil, fmt.Errorf("failed to get booking %s: %w", editReq.BookingID, err)
	}

	origServices, err := queryBookingServicesWithDetails(ctx, editReq.BookingID)
	if err != nil {
		return nil, fmt.Errorf("failed to get booking services for %s: %w", editReq.BookingID, err)
	}

	var proposedServices []EditServiceDetail
	if len(editReq.NewServices) > 0 && !editReq.HasOverrides {
		proposedServices, err = queryServiceDetailsByIDs(ctx, editReq.NewServices)
		if err != nil {
			return nil, fmt.Errorf("failed to get service details: %w", err)
		}
	} else {
		proposedServices = origServices
	}

	var proposedStartTime *time.Time
	if editReq.NewStartTime != nil {
		proposedStartTime = editReq.NewStartTime
	} else {
		proposedStartTime = &bStartTime
	}

	var proposedNotes *string
	if editReq.Notes != nil {
		proposedNotes = editReq.Notes
	} else {
		proposedNotes = bNotes
	}

	origDuration := sumServiceDurations(origServices)
	proposedDuration := sumServiceDurations(proposedServices)

	origEndTime := bStartTime.Add(time.Duration(origDuration) * time.Minute)
	var proposedEndTime *time.Time
	if editReq.NewStartTime != nil {
		et := editReq.NewStartTime.Add(time.Duration(proposedDuration) * time.Minute)
		proposedEndTime = &et
	} else {
		proposedEndTime = &origEndTime
	}

	// Non-fatal: still return the request without user details
	userSummary, err := queryUserSummary(ctx, editReq.RequestedBy)
	if err != nil {
		// Non-fatal: still return the request without user details
		log.Printf("Failed to get user summary for %s: %v", editReq.RequestedBy, err)
	}

	result := &EnrichedEditRequest{
		ID:          editReq.ID,
		BookingID:   editReq.BookingID,
		RequestedBy: editReq.RequestedBy,
		RequestedAt: editReq.UpdatedAt,
		Notes:       editReq.Notes,
		Original: &EditSnapshot{
			StartTime: &bStartTime,
			EndTime:   &origEndTime,
			Services:  origServices,
			Notes:     bNotes,
		},
		Proposed: &EditSnapshot{
			StartTime: proposedStartTime,
			EndTime:   proposedEndTime,
			Services:  proposedServices,
			Notes:     proposedNotes,
		},
		User: userSummary,
	}

	return result, nil
}

// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
	rows, err := db.Conn.Query(ctx, `
		SELECT s.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
		JOIN services s ON bs.service_id = s.id
		WHERE bs.booking_id = $1
		UNION ALL
		SELECT cs.id, cs.name,
			COALESCE(bcs.override_price, cs.price),
			COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
		FROM booking_custom_services bcs
		JOIN custom_services cs ON bcs.custom_service_id = cs.id
		WHERE bcs.booking_id = $1
		ORDER BY name
	`, bookingID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var services []EditServiceDetail
	for rows.Next() {
		var svc EditServiceDetail
		if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
			return nil, err
		}
		services = append(services, svc)
	}
	if services == nil {
		services = []EditServiceDetail{}
	}
	return services, rows.Err()
}

// queryServiceDetailsByIDs returns service details for the given service IDs.
func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditServiceDetail, error) {
	if len(serviceIDs) == 0 {
		return []EditServiceDetail{}, nil
	}

	rows, err := db.Conn.Query(ctx, `
		SELECT id, name, price, duration_minutes
		FROM services
		WHERE id = ANY($1)
		ORDER BY name
	`, serviceIDs)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var services []EditServiceDetail
	for rows.Next() {
		var svc EditServiceDetail
		if err := rows.Scan(&svc.ID, &svc.Name, &svc.Price, &svc.DurationMinutes); err != nil {
			return nil, err
		}
		services = append(services, svc)
	}
	if services == nil {
		services = []EditServiceDetail{}
	}
	return services, rows.Err()
}

// sumServiceDurations returns the total duration in minutes from a slice of EditServiceDetail.
func sumServiceDurations(services []EditServiceDetail) int {
	total := 0
	for _, s := range services {
		total += s.DurationMinutes
	}
	if total == 0 {
		return 60 // fallback
	}
	return total
}

// queryUserSummary fetches user details for the enriched edit request response.
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
	var summary EditUserSummary
	var prevFirstName, prevLastName sql.NullString
	err := db.Conn.QueryRow(ctx, `
		SELECT u.id, u.fn, u.email, u.phone,
		       nh.previous_first_name, nh.previous_last_name
		FROM users u
		LEFT JOIN LATERAL (
			SELECT previous_first_name, previous_last_name
			FROM name_history
			WHERE user_id = u.id
			ORDER BY changed_at DESC
			LIMIT 1
		) nh ON true
		WHERE u.id = $1
	`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone,
		&prevFirstName, &prevLastName)
	if err != nil {
		return nil, err
	}
	if prevFirstName.Valid {
		summary.PreviousFirstName = &prevFirstName.String
	}
	if prevLastName.Valid {
		summary.PreviousLastName = &prevLastName.String
	}
	return &summary, nil
}

// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
func DeleteEditRequestHandler(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
	}

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

	if ownerID != userID {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

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

	// Delete the edit request for this booking
	res, err := tx.Exec(r.Context(), `
		DELETE FROM booking_edit_requests
		WHERE booking_id = $1 AND requested_by = $2
	`, bookingID, userID)
	if err != nil {
		log.Printf("Failed to delete edit request for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	rowsAffected := res.RowsAffected()
	if rowsAffected == 0 {
		http.Error(w, "No edit request found", http.StatusNotFound)
		return
	}

	if _, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers
		WHERE description = $1
	`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
		log.Printf("ALERT: failed to delete time_blocker: %v", err)
	}

	// Delete the admin notification for this edit request
	_, err = tx.Exec(r.Context(), `
		DELETE FROM admin_notifications
		WHERE booking_id = $1 AND reason = 'edit_requested' AND user_id = $2
	`, bookingID, userID)
	if err != nil {
		log.Printf("Failed to delete 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 {
		log.Printf("Failed to commit delete edit request: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// RequestEditHandler allows a user to request an edit to their booking
func RequestEditHandler(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 struct {
		NewStartTime *time.Time `json:"new_start_time,omitempty"`
		NewServices  []string   `json:"new_services"`
		Notes        *string    `json:"notes,omitempty" validate:"omitempty,max=1000000"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

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

	// M8
	// L5

	// Validate: at least one of new_start_time, new_services, or notes must be provided
	if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
		http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
		return
	}

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

	if ownerID != userID {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

	// Check booking is not already completed/cancelled
	var currentStatus string
	var currentStartTime time.Time
	var depositRequired bool
	err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &currentStartTime, &depositRequired)
	if err != nil {
		log.Printf("Failed to get booking status %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
	}

	// Query payment and timing info (used for validation AND auto-approval later)
	var hasPayments bool
	hoursUntilCurrent := currentStartTime.Sub(clock.Now()).Hours()
//nolint:errcheck // zero value is acceptable fallback on scan failure
	_ = db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)

	// Check if booking has discounts (affects auto-approval decisions)
	var hasDiscounts bool
//nolint:errcheck // zero value is acceptable fallback on scan failure
	_ = db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts)

	if req.NewStartTime != nil && !req.NewStartTime.Equal(currentStartTime) {
		if hasPayments && hoursUntilCurrent < 72 {
			http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook (note: cancellation fees may apply based on our deposit policy).", http.StatusForbidden)
			return
		}

		if !hasPayments && hoursUntilCurrent < 24 {
			http.Error(w, "This booking is too close to the appointment time to reschedule online. Please contact us to discuss options, or cancel and rebook.", http.StatusForbidden)
			return
		}

		// Warn when within 72h with no payments (24-72h window — close enough
		// to reschedule but counts toward no-show history).
		if !hasPayments && hoursUntilCurrent < 72 {
			w.Header().Set("X-No-Show-Warning", "Rescheduling within 72h counts as a no-show towards your deposit obligations. Two no-shows within 6 months will require deposits on future bookings.")
		}
	}
	if len(req.NewServices) > 0 {
		var overrideCount int
		err = db.Conn.QueryRow(r.Context(), `
			SELECT COUNT(*) FROM (
				SELECT 1 FROM booking_services
				WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
				UNION ALL
				SELECT 1 FROM booking_custom_services
				WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
			) overrides
		`, bookingID).Scan(&overrideCount)
		if err != nil {
			log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if overrideCount > 0 {
			http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden)
			return
		}
	}

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

	// Delete any existing edit request for this booking (upsert behavior)
	_, err = tx.Exec(r.Context(), `
		DELETE FROM booking_edit_requests
		WHERE booking_id = $1 AND requested_by = $2
	`, bookingID, userID)
	if err != nil {
		log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Create edit request - has_overrides is false since user can't override
	var editReq BookingEditRequest
	err = tx.QueryRow(r.Context(), `
		INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
		VALUES ($1, $2, $3, $4, $5, $6)
		RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
	`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
		&editReq.ID,
		&editReq.BookingID,
		&editReq.RequestedBy,
		&editReq.NewStartTime,
		&editReq.NewServices,
		&editReq.Notes,
		&editReq.HasOverrides,
		&editReq.UpdatedAt,
	)
	if err != nil {
		log.Printf("Failed to create edit request for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Auto-approve if no payments exist, the booking is >48h away,
	// and no discounts+time-change combo (requires admin review)
	if !hasPayments && hoursUntilCurrent > 48 && !(hasDiscounts && req.NewStartTime != nil) {
		if req.NewStartTime != nil {
			// Calculate duration for the new time
			var durMinutes int
			if len(req.NewServices) > 0 {
				if err := tx.QueryRow(r.Context(), `
					SELECT COALESCE(SUM(s.duration_minutes), 60)
					FROM services s WHERE s.id = ANY($1)
				`, req.NewServices).Scan(&durMinutes); err != nil {
					log.Printf("Failed to get duration for new services: %v", err)
					durMinutes = 60
				}
			} else {
				if err := tx.QueryRow(r.Context(), `
				SELECT total_duration_minutes FROM bookings WHERE id = $1
			`, bookingID).Scan(&durMinutes); err != nil {
					log.Printf("Failed to get duration for existing services: %v", err)
					durMinutes = 60
				}
			}
			if durMinutes <= 0 {
				durMinutes = 60
			}

			// Quick overlap check — block if slot is taken
			newEnd := req.NewStartTime.Add(time.Duration(durMinutes) * time.Minute)

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

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

		// Update booking start_time, notes, and services directly
		if req.NewStartTime != nil || req.Notes != nil {
			var setClauses []string
			var args []any
			argNum := 1
			if req.NewStartTime != nil {
				setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
				args = append(args, *req.NewStartTime)
				argNum++
			}
			if req.Notes != nil {
				setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
				args = append(args, *req.Notes)
				argNum++
			}
			setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
			args = append(args, clock.Now())
			argNum++
			args = append(args, bookingID)
			query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
			if _, err := tx.Exec(r.Context(), query, args...); err != nil {
				log.Printf("Failed to auto-approve booking update %s: %v", bookingID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}

		// Update services if requested
		if len(req.NewServices) > 0 {
			if _, err := tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID); err != nil {
				log.Printf("Failed to delete services for booking %s: %v", bookingID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			if _, err := tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID); err != nil {
				log.Printf("Failed to delete custom services for booking %s: %v", bookingID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
			for _, sid := range req.NewServices {
				if _, err := tx.Exec(r.Context(), "INSERT INTO booking_services (booking_id, service_id) VALUES ($1, $2)", bookingID, sid); err != nil {
					log.Printf("Failed to insert auto-approve service %s: %v", sid, err)
					http.Error(w, "Internal server error", http.StatusInternalServerError)
					return
				}
			}
		}

		// Clean up the edit request and reservations
		if _, err := tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", editReq.ID); err != nil {
			log.Printf("Failed to delete edit request %s: %v", editReq.ID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if _, err := tx.Exec(r.Context(), `DELETE FROM time_blockers WHERE description = $1`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
			log.Printf("Failed to delete time blocker for edit request %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		if _, err := tx.Exec(r.Context(), `UPDATE admin_notifications SET acknowledged_at = NOW() WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL`, bookingID); err != nil {
			log.Printf("Failed to acknowledge notification 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 auto-approve edit request: %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(map[string]any{
			"auto_approved": true,
			"edit_request":  editReq,
		})
		return
	}

	if req.NewStartTime != nil {
		if _, err := tx.Exec(r.Context(), `
			DELETE FROM time_blockers
			WHERE description = $1
		`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
			log.Printf("ALERT: failed to delete time_blocker: %v", err)
		}

		var durationMinutes int
		if len(req.NewServices) > 0 {
//nolint:errcheck // zero value is acceptable fallback on scan failure
			_ = tx.QueryRow(r.Context(), `
				SELECT COALESCE(SUM(dur), 60) FROM (
					SELECT s.duration_minutes AS dur
					FROM services s
					WHERE s.id = ANY($1)
					UNION ALL
					SELECT cs.duration_minutes
					FROM custom_services cs
					WHERE cs.id = ANY($1)
				) sub
			`, req.NewServices).Scan(&durationMinutes)
		} else {
//nolint:errcheck // zero value is acceptable fallback on scan failure
			_ = tx.QueryRow(r.Context(), `
				SELECT total_duration_minutes FROM bookings WHERE id = $1
			`, bookingID).Scan(&durationMinutes)
		}

		_, err = tx.Exec(r.Context(), `
			INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
			VALUES ($1, $2, $3, $4)
		`, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID)
		if err != nil {
			log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	// Always create low-priority notification for edit requests
	_, err = tx.Exec(r.Context(), `
		DELETE FROM admin_notifications
		WHERE booking_id = $1 AND reason = 'edit_requested'
	`, bookingID)
	if err != nil {
		log.Printf("Failed to delete old admin notification for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

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

	// If booking is pending, also create high-priority approval notification
	if currentStatus == "pending" {
		_, err = tx.Exec(r.Context(), `
			UPDATE admin_notifications
			SET acknowledged_at = NOW()
			WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL
		`, bookingID)
		if err != nil {
			log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
		}

		_, err = tx.Exec(r.Context(), `
			INSERT INTO admin_notifications (reason, booking_id, user_id)
			VALUES ('pending_booking', $1, $2)
		`, bookingID, userID)
		if err != nil {
			log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
		}
	}

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

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

// AdminListEditRequestsHandler returns all edit requests
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
	baseQuery := `
		SELECT
		       ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
		       ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
		       b.start_time as original_start_time, b.status as booking_status,
		       u.fn as user_name
		FROM booking_edit_requests ber
		JOIN bookings b ON ber.booking_id = b.id
		JOIN users u ON ber.requested_by = u.id
	`

	var args []any

	baseQuery += " ORDER BY ber.updated_at DESC"

	var total int

	// Count query (no ORDER BY needed).
//nolint:errcheck // zero value is acceptable fallback on scan failure
	_ = db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)

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

	var requests []BookingEditRequest
	for rows.Next() {
		var req BookingEditRequest
		var origStartTime time.Time
		var bookingStatus string
		var userName string
		var newServices []string

		err := rows.Scan(
			&req.ID,
			&req.BookingID,
			&req.RequestedBy,
			&req.NewStartTime,
			&newServices,
			&req.Notes,
			&req.HasOverrides,
			&req.UpdatedAt,
			&origStartTime,
			&bookingStatus,
			&userName,
		)
		if err != nil {
			log.Printf("Failed to scan edit request: %v", err)
			continue
		}

		req.NewServices = newServices

		req.Booking = &Booking{
			ID:        req.BookingID,
			StartTime: origStartTime,
			Status:    bookingStatus,
		}
		req.User = &UserSummary{
			ID:       req.RequestedBy,
			FullName: userName,
		}

		requests = append(requests, req)
	}

	if requests == nil {
		requests = []BookingEditRequest{}
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"requests": requests,
		"total":    total,
	})
}

// AdminApproveEditRequestHandler approves an edit request and updates the booking
func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
	adminID, ok := r.Context().Value(mw.UserIDKey).(string)
	if !ok || adminID == "" {
		http.Error(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	requestID := chi.URLParam(r, "request_id")
	if requestID == "" || !validators.IsValidID(requestID) {
		http.Error(w, "Edit request not found", http.StatusNotFound)
		return
	}

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

	// Get the edit request
	var bookingID string
	var newStartTime *time.Time
	var newServices []string
	var notes *string
	var hasOverrides bool
	err = tx.QueryRow(r.Context(), `
		SELECT booking_id, new_start_time, new_services, notes, has_overrides
		FROM booking_edit_requests
		WHERE id = $1
	`, requestID).Scan(&bookingID, &newStartTime, &newServices, &notes, &hasOverrides)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Edit request not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get edit request %s: %v", requestID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// If new_services provided and has_overrides is true, block with error
	if len(newServices) > 0 && hasOverrides {
		http.Error(w, "Cannot change services on a booking that has overrides. Please update services manually.", http.StatusForbidden)
		return
	}

	// Check for applied discounts (admin warning only — discounts remain locked in)
	var discountCount int
	if err := tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount); err != nil {
		log.Printf("ADMIN APPROVE EDIT: Failed to check discounts: %v", err)
	}
	if discountCount > 0 {
		log.Printf("ADMIN APPROVE EDIT: Booking %s has %d discount(s) applied — discounts remain locked in after reschedule", bookingID, discountCount)
	}

	// Calculate duration for overlap check - use overrides if has_overrides is true
	var durationMinutes int
	if hasOverrides {
		// Use the existing booking_services with overrides
		err = tx.QueryRow(r.Context(), `
			SELECT total_duration_minutes FROM bookings WHERE id = $1
		`, bookingID).Scan(&durationMinutes)
	} else {
		// Use standard durations or new_services if provided
		if len(newServices) > 0 {
			// Use new services to calculate duration
			err = tx.QueryRow(r.Context(), `
				SELECT COALESCE(SUM(s.duration_minutes), 60)
				FROM services s
				WHERE s.id = ANY($1)
			`, newServices).Scan(&durationMinutes)
		} else {
			// Use existing booking services
			err = tx.QueryRow(r.Context(), `
				SELECT COALESCE(SUM(dur), 60) FROM (
					SELECT s.duration_minutes AS dur
					FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
					UNION ALL
					SELECT cs.duration_minutes
					FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = $1
				) sub
			`, bookingID).Scan(&durationMinutes)
		}
	}
	if err != nil {
		log.Printf("Failed to calculate duration: %v", err)
		durationMinutes = 60 // fallback
	}

	// Check for overlapping bookings if start time is being changed
	if newStartTime != nil {
		newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)

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

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

		// Delete the edit request's reservation BEFORE checking time blockers.
		// The reservation (RESERVATION:edit_request:*) was created by RequestEditHandler
		// to temporarily hold the slot. If not removed first, it would show up as a
		// blocker and prevent the approve from succeeding.
		if _, delErr := tx.Exec(r.Context(), `
			DELETE FROM time_blockers
			WHERE description = $1
		`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); delErr != nil {
			log.Printf("ALERT: failed to delete time_blocker: %v", delErr)
		}

		blockerOverlap, _, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime, &adminID)
		if err != nil {
			log.Printf("Failed to check time blocker overlap: %v", err)
		}
		if blockerOverlap {
			http.Error(w, "This edit would overlap with a time blocker", http.StatusConflict)
			return
		}

		// Check working hours (admin gets warning)
		// newStartTime from the DB is UTC; convert to London for weekday/daysToMonday
		// so BST dates (e.g. 00:30 BST = 23:30 UTC previous day) compute correctly.
		localStart := newStartTime.In(londonLocation)
		weekday := int((localStart.Weekday() + 6) % 7)
		bookingTime := localStart.Format("15:04:05")
		daysToMonday := int(localStart.Weekday())
		if daysToMonday == 0 {
			daysToMonday = 7 // Sunday -> next Monday
		}
		tm := localStart.AddDate(0, 0, -daysToMonday+1)
		// Use UTC midnight for weekStart so PostgreSQL DATE comparison works
		// correctly with TIMESTAMPTZ. London-midnight during BST = 23:00 UTC
		// previous day, which would shift the DATE comparison by -1 day.
		weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, time.UTC)

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

		if isClosed {
			http.Error(w, "Cannot approve: the proposed time falls during a period when the salon is closed", http.StatusConflict)
			return
		}
	}

	// Build update query for bookings table
	if newStartTime != nil || notes != nil {
		// setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N").
		// Column names are never derived from user input. User values are in args and always parameterised.
		var setClauses []string
		var args []any
		argNum := 1

		if newStartTime != nil {
			setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
			args = append(args, *newStartTime)
			argNum++
		}
		if notes != nil {
			setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
			args = append(args, *notes)
			argNum++
		}
		setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
		args = append(args, clock.Now())
		argNum++

		args = append(args, bookingID)

		query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
		_, err = tx.Exec(r.Context(), query, args...)
		if err != nil {
			log.Printf("Failed to update booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
	}

	// Handle new_services (only if has_overrides is false)
	if len(newServices) > 0 && !hasOverrides {
		// Delete existing booking_services
		_, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
		if err != nil {
			log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}
		_, err = tx.Exec(r.Context(), "DELETE FROM booking_custom_services WHERE booking_id = $1", bookingID)
		if err != nil {
			log.Printf("Failed to delete existing custom services for booking %s: %v", bookingID, err)
			http.Error(w, "Internal server error", http.StatusInternalServerError)
			return
		}

		// Insert new services
		for _, serviceID := range newServices {
			_, err = tx.Exec(r.Context(), `
				INSERT INTO booking_services (booking_id, service_id)
				VALUES ($1, $2)
			`, bookingID, serviceID)
			if err != nil {
				log.Printf("Failed to insert booking service %s: %v", serviceID, err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}
		}
	}

	// Delete the edit request row (not update status)
	_, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID)
	if err != nil {
		log.Printf("Failed to delete edit request %s: %v", requestID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// Acknowledge the admin notification for this edit request
	_, err = tx.Exec(r.Context(), `
		UPDATE admin_notifications
		SET acknowledged_at = NOW()
		WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
	`, bookingID)
	if err != nil {
		log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// TODO: Notify user that their edit request was approved (blocked on E5 SMTP)

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

	w.WriteHeader(http.StatusNoContent)
}

// AdminRejectEditRequestHandler rejects an edit request by deleting it and acknowledging the admin notification
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
	requestID := chi.URLParam(r, "request_id")
	if requestID == "" || !validators.IsValidID(requestID) {
		http.Error(w, "Edit request not found", http.StatusNotFound)
		return
	}

	// First get the booking_id from the edit request before deleting
	var bookingID string
	err := db.Conn.QueryRow(r.Context(), `
		SELECT booking_id FROM booking_edit_requests WHERE id = $1
	`, requestID).Scan(&bookingID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Edit request not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get edit request %s: %v", requestID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

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

	// Delete the edit request
	_, err = tx.Exec(r.Context(), `
		DELETE FROM booking_edit_requests
		WHERE id = $1
	`, requestID)
	if err != nil {
		log.Printf("Failed to reject edit request %s: %v", requestID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if _, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers
		WHERE description = $1
	`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil {
		log.Printf("ALERT: failed to delete time_blocker: %v", err)
	}

	// Acknowledge the admin notification for this edit request
	_, err = tx.Exec(r.Context(), `
		UPDATE admin_notifications
		SET acknowledged_at = NOW()
		WHERE booking_id = $1 AND reason = 'edit_requested' AND acknowledged_at IS NULL
	`, bookingID)
	if err != nil {
		log.Printf("Failed to acknowledge admin notification for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	// TODO: Notify user that their edit request was denied with option to cancel (blocked on E5 SMTP)

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

	w.WriteHeader(http.StatusNoContent)
}

// GetMyEditRequestHandler returns the pending edit request for a specific booking the user owns.
// GET /api/bookings/{id}/edit-request
func GetMyEditRequestHandler(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 ownerID string
	err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "Booking not found", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	if ownerID != userID {
		http.Error(w, "Access denied", http.StatusForbidden)
		return
	}

	var editReq BookingEditRequest
	var newServices []string
	err = db.Conn.QueryRow(r.Context(), `
		SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
		FROM booking_edit_requests
		WHERE booking_id = $1 AND requested_by = $2
	`, bookingID, userID).Scan(
		&editReq.ID,
		&editReq.BookingID,
		&editReq.RequestedBy,
		&editReq.NewStartTime,
		&newServices,
		&editReq.Notes,
		&editReq.HasOverrides,
		&editReq.UpdatedAt,
	)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			_ = json.NewEncoder(w).Encode(map[string]any{
				"edit_request": nil,
			})
			return
		}
		log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	editReq.NewServices = newServices

	enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
	if err != nil {
		log.Printf("Failed to build enriched edit request: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"edit_request": enriched,
	})
}

// GetMyEditRequestsHandler returns all pending edit requests for the current user across all bookings.
// GET /api/bookings/edit-requests
func GetMyEditRequestsHandler(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
	}

	rows, err := db.Conn.Query(r.Context(), `
		SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
		FROM booking_edit_requests
		WHERE requested_by = $1
		ORDER BY updated_at DESC
	`, userID)
	if err != nil {
		log.Printf("Failed to fetch edit requests for user %s: %v", userID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	// Collect all edit requests first to avoid nested queries on the same tx connection.
	var editReqs []BookingEditRequest
	for rows.Next() {
		var editReq BookingEditRequest
		var newServices []string
		if err := rows.Scan(
			&editReq.ID,
			&editReq.BookingID,
			&editReq.RequestedBy,
			&editReq.NewStartTime,
			&newServices,
			&editReq.Notes,
			&editReq.HasOverrides,
			&editReq.UpdatedAt,
		); err != nil {
			log.Printf("Failed to scan edit request: %v", err)
			continue
		}
		editReq.NewServices = newServices
		editReqs = append(editReqs, editReq)
	}

	var enrichedRequests []*EnrichedEditRequest
	for _, editReq := range editReqs {
		enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
		if err != nil {
			log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
			continue
		}
		enrichedRequests = append(enrichedRequests, enriched)
	}

	if enrichedRequests == nil {
		enrichedRequests = []*EnrichedEditRequest{}
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"edit_requests": enrichedRequests,
	})
}

// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
// GET /api/admin/bookings/edit-requests
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Conn.Query(r.Context(), `
		SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
		FROM booking_edit_requests
		ORDER BY updated_at DESC
	`)
	if err != nil {
		log.Printf("Failed to fetch all edit requests: %v", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	// Collect all edit requests first to avoid nested queries on the same tx connection.
	var editReqs []BookingEditRequest
	for rows.Next() {
		var editReq BookingEditRequest
		var newServices []string
		if err := rows.Scan(
			&editReq.ID,
			&editReq.BookingID,
			&editReq.RequestedBy,
			&editReq.NewStartTime,
			&newServices,
			&editReq.Notes,
			&editReq.HasOverrides,
			&editReq.UpdatedAt,
		); err != nil {
			log.Printf("Failed to scan edit request: %v", err)
			continue
		}
		editReq.NewServices = newServices
		editReqs = append(editReqs, editReq)
	}

	var enrichedRequests []*EnrichedEditRequest
	for _, editReq := range editReqs {
		enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
		if err != nil {
			log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
			continue
		}
		enrichedRequests = append(enrichedRequests, enriched)
	}

	if enrichedRequests == nil {
		enrichedRequests = []*EnrichedEditRequest{}
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"edit_requests": enrichedRequests,
	})
}

// AdminGetBookingEditRequestHandler returns the pending edit request for a specific booking.
// GET /api/admin/bookings/{id}/edit-request
func AdminGetBookingEditRequestHandler(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 editReq BookingEditRequest
	var newServices []string
	err := db.Conn.QueryRow(r.Context(), `
		SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
		FROM booking_edit_requests
		WHERE booking_id = $1
	`, bookingID).Scan(
		&editReq.ID,
		&editReq.BookingID,
		&editReq.RequestedBy,
		&editReq.NewStartTime,
		&newServices,
		&editReq.Notes,
		&editReq.HasOverrides,
		&editReq.UpdatedAt,
	)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
			return
		}
		log.Printf("Failed to get edit request for booking %s: %v", bookingID, err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
	editReq.NewServices = newServices

	enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
		if err != nil {
			log.Printf("Failed to build enriched edit request: %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(map[string]any{
			"edit_request": enriched,
		})
}

// ========================================
// NO-SHOW HELPER FUNCTIONS
// ========================================

// CountUnforgivenNoShows counts the number of no-shows in the last 6 months
// that have not been forgiven (not in forgiven_no_shows table)
func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) {
	var count int
	err := db.Conn.QueryRow(ctx, `
		SELECT COUNT(*)
		FROM bookings b
		WHERE b.user_id = $1
		  AND b.status = 'no_show'
		  AND b.start_time >= NOW() - INTERVAL '6 months'
		  AND NOT EXISTS (
		    SELECT 1 FROM forgiven_no_shows WHERE booking_id = b.id
		  )
	`, userID).Scan(&count)
	return count, err
}

// ApplyDepositsIfNeeded checks if user has 2+ unforgiven no-shows
// and applies 3 deposits if so. Returns true if deposits were applied.
func ApplyDepositsIfNeeded(ctx context.Context, q db.Querier, userID string) (bool, error) {
	count, err := CountUnforgivenNoShows(ctx, userID)
	if err != nil {
		return false, err
	}
	if count >= 2 {
		// Apply 3 deposits
		_, err := q.Exec(ctx, `
			UPDATE users SET deposits_required = 3 WHERE id = $1
		`, userID)
		if err != nil {
			return false, err
		}
		return true, nil
	}
	return false, nil
}
