package scheduling

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"log/slog"
	"net/http"
	"time"

	"crussell/db"
	"crussell/clock"
	"crussell/internal/validators"
	"crussell/mw"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5"
	"github.com/robfig/cron/v3"
)

// --- Types ---

type TimeBlocker struct {
	ID              string    `json:"id"`
	StartTime       time.Time `json:"start_time"`
	DurationMinutes int       `json:"duration_minutes"`
	Description     string    `json:"description,omitempty"`
	CronExpression  *string   `json:"cron_expression,omitempty"`
	CreatedAt       time.Time `json:"created_at"`
	CreatedBy       *string   `json:"created_by,omitempty"`
}

type CreateTimeBlockerRequest struct {
	StartTime       time.Time `json:"start_time" validate:"required"`
	DurationMinutes int       `json:"duration_minutes" validate:"required,gt=0"`
	Description     string    `json:"description,omitempty" validate:"omitempty,max=500"`
	CronExpression  *string   `json:"cron_expression,omitempty" validate:"omitempty,max=500"`
}

// --- List Time Blockers ---
// GET /api/admin/time-blockers
// Returns:
// - Future one-off blockers (cron_expression IS NULL AND start_time >= now)
// - ALL recurring blockers (cron_expression IS NOT NULL)
func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
	// Optional date range filtering
	startStr := r.URL.Query().Get("start")
	endStr := r.URL.Query().Get("end")

	var rows pgx.Rows
	var err error

	if startStr != "" && endStr != "" {
		// Filter by date range
		start, err1 := time.Parse("2006-01-02", startStr)
		end, err2 := time.Parse("2006-01-02", endStr)
		if err1 != nil || err2 != nil {
			http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest)
			return
		}
		start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
		end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)

		// Get one-off blockers in range + ALL recurring blockers
		rows, err = db.Conn.Query(r.Context(), `
			SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
			FROM time_blockers
			WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
			   OR (cron_expression IS NOT NULL)
			ORDER BY 
				CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END,
				start_time DESC
		`, start, end)
	} else {
		// Get future one-off blockers + ALL recurring blockers
		now := clock.Now()
		rows, err = db.Conn.Query(r.Context(), `
			SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
			FROM time_blockers
			WHERE (cron_expression IS NULL AND start_time >= $1)
			   OR (cron_expression IS NOT NULL)
			ORDER BY 
				CASE WHEN cron_expression IS NULL THEN 0 ELSE 1 END,
				start_time DESC
			LIMIT 100
		`, now)
	}

	if err != nil {
		http.Error(w, "failed to fetch time blockers", http.StatusInternalServerError)
		return
	}
	defer rows.Close()

	var blockers []TimeBlocker
	for rows.Next() {
		var b TimeBlocker
		if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
			http.Error(w, "failed to scan time blocker", http.StatusInternalServerError)
			return
		}
		blockers = append(blockers, b)
	}

	if err := rows.Err(); err != nil {
		http.Error(w, "error iterating time blockers", http.StatusInternalServerError)
		return
	}

	if blockers == nil {
		blockers = []TimeBlocker{}
	}

	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(blockers)
}

// --- Create Time Blocker ---
// POST /api/admin/time-blockers
func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
	var req CreateTimeBlockerRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
		return
	}

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

	// Validate required fields
	if req.StartTime.IsZero() {
		http.Error(w, "start_time is required", http.StatusBadRequest)
		return
	}
	if req.DurationMinutes <= 0 {
		http.Error(w, "duration_minutes must be greater than 0", http.StatusBadRequest)
		return
	}

	// Get admin user ID from context
	var createdBy *string
	if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
		createdBy = &userID
	}

	tx, err := db.Conn.Begin(r.Context())
	if err != nil {
		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)
    }
}()

	// Insert the time blocker
	var blocker TimeBlocker
	err = tx.QueryRow(r.Context(), `
		INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
		VALUES ($1, $2, $3, $4, $5)
		RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by
	`, req.StartTime, req.DurationMinutes, req.Description, req.CronExpression, createdBy).Scan(
		&blocker.ID, &blocker.StartTime, &blocker.DurationMinutes, &blocker.Description,
		&blocker.CronExpression, &blocker.CreatedAt, &blocker.CreatedBy,
	)
	if err != nil {
		http.Error(w, "failed to create time blocker", 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.StatusCreated)
	_ = json.NewEncoder(w).Encode(blocker)
}

// --- Delete Time Blocker ---
// DELETE /api/admin/time-blockers/{id}
func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	if id == "" || !validators.IsValidID(id) {
		http.Error(w, "time blocker not found", http.StatusNotFound)
		return
	}

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

	result, err := tx.Exec(r.Context(), `
		DELETE FROM time_blockers WHERE id = $1
	`, id)
	if err != nil {
		http.Error(w, "failed to delete time blocker", http.StatusInternalServerError)
		return
	}

	rowsAffected := result.RowsAffected()
	if rowsAffected == 0 {
		http.Error(w, "time blocker not found", http.StatusNotFound)
		return
	}

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

	w.WriteHeader(http.StatusNoContent)
}

// --- Helper: Get Time Blockers in Range ---

// --- Helper: Get Time Blockers in Range ---
// Returns blockers for the given date range, expanded for recurring blockers.
// If excludeUserID is non-nil, RESERVATION entries owned by that user are excluded.
// Used by GetAvailableHours to subtract blocked time from available slots
func GetTimeBlockersInRange(ctx context.Context, start, end time.Time, excludeUserID *string) ([]TimeBlocker, error) {
	rows, err := db.Conn.Query(ctx, `
		SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
		FROM time_blockers
		WHERE ((cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
		   OR (cron_expression IS NOT NULL))
		   AND ($3::text IS NULL OR NOT (description LIKE 'RESERVATION:%' AND created_by = $3))
		ORDER BY start_time
	`, start, end, excludeUserID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var blockers []TimeBlocker
	for rows.Next() {
		var b TimeBlocker
		if err := rows.Scan(&b.ID, &b.StartTime, &b.DurationMinutes, &b.Description, &b.CronExpression, &b.CreatedAt, &b.CreatedBy); err != nil {
			return nil, err
		}
		if b.CronExpression != nil {
			occurrences := expandCronOccurrences(b, start, end)
			blockers = append(blockers, occurrences...)
		} else {
			blockers = append(blockers, b)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	return blockers, nil
}

// expandCronOccurrences expands a recurring blocker to all occurrences within a date range
// The cron expression defines the pattern, and the blocker's start_time provides the time-of-day
func expandCronOccurrences(blocker TimeBlocker, rangeStart, rangeEnd time.Time) []TimeBlocker {
	if blocker.CronExpression == nil {
		return nil
	}

	parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
	schedule, err := parser.Parse(*blocker.CronExpression)
	if err != nil {
		log.Printf("Invalid cron expression '%s': %v", *blocker.CronExpression, err)
		return nil
	}

	// Get the time-of-day from the blocker's start_time in London time,
	// so the recurrence fires at the same wall-clock time regardless of DST.
	blockerLondon := blocker.StartTime.In(londonLocation)
	blockerHour := blockerLondon.Hour()
	blockerMinute := blockerLondon.Minute()

	var occurrences []TimeBlocker

	// Start from the beginning of the range using London timezone,
	// ensuring the same wall-clock time applies year-round.
	current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, londonLocation)

	// Find the first occurrence on or after rangeStart
	firstNext := schedule.Next(current.Add(-time.Second))
	if firstNext.Before(rangeStart) {
		current = schedule.Next(firstNext)
	} else {
		current = firstNext
	}

	// Collect all occurrences within the range
	for current.Before(rangeEnd) || current.Equal(rangeEnd) {
		// Create a new blocker instance for this occurrence
		occurrence := TimeBlocker{
			ID:              blocker.ID,
			StartTime:       current,
			DurationMinutes: blocker.DurationMinutes,
			Description:     blocker.Description,
			CronExpression:  blocker.CronExpression,
			CreatedAt:       blocker.CreatedAt,
			CreatedBy:       blocker.CreatedBy,
		}
		occurrences = append(occurrences, occurrence)

		// Get next occurrence
		next := schedule.Next(current)
		if next.Equal(current) {
			break // Prevent infinite loop if schedule isn't advancing
		}
		current = next
	}

	return occurrences
}

// --- Helper: Check Time Blocker Overlap ---
// Returns (hasOverlap, blockerDescription, error)
// Used by booking handlers to check for blocker conflicts.
// If excludeUserID is non-nil, RESERVATION entries owned by that user are skipped.
func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time, excludeUserID *string) (bool, string, error) {
	// Get all blockers in an expanded range that could overlap
	// We need to look further back because recurring blockers could span multiple periods
	searchStart := startTime.AddDate(0, -1, 0) // Look back 1 month for recurring patterns
	searchEnd := endTime

	blockers, err := GetTimeBlockersInRange(ctx, searchStart, searchEnd, excludeUserID)
	if err != nil {
		return false, "", err
	}

	// Check each blocker (one-off or expanded recurring) for overlap
	for _, blocker := range blockers {
		blockerEnd := blocker.StartTime.Add(time.Duration(blocker.DurationMinutes) * time.Minute)

		// Check if the booking overlaps with the blocker
		// Overlap condition: booking_start < blocker_end AND booking_end > blocker_start
		if startTime.Before(blockerEnd) && endTime.After(blocker.StartTime) {
			desc := blocker.Description
			if desc == "" {
				desc = "Time blocked"
			}
			if blocker.CronExpression != nil {
				desc = fmt.Sprintf("%s (recurring: %s)", desc, *blocker.CronExpression)
			}
			return true, desc, nil
		}
	}

	return false, "", nil
}

// CleanupOldReservations deletes expired reservations:
// - Logged-in (RESERVATION:user): older than 1 hour
// - Anonymous (RESERVATION:anon): older than 10 minutes
// - Admin walk-in (RESERVATION:admin:walkin:%): older than 15 minutes
// - Admin call-in (RESERVATION:admin:callin:%): older than 15 minutes
// - Edit request (RESERVATION:edit_request:%): older than 24 hours
// - Payment in-flight (PAYMENT_IN_FLIGHT:%): TTL via duration_minutes column
//   (AcquirePaymentLock sets duration_minutes = PaymentLockDuration = 5min and
//   start_time = NOW(), so the condition evaluates to "cleanup after 5 minutes".
func CleanupOldReservations(ctx context.Context) (int, error) {
	oneHourAgo := clock.Now().Add(-1 * time.Hour)
	tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
	fifteenMinutesAgo := clock.Now().Add(-15 * time.Minute)
	twentyFourHoursAgo := clock.Now().Add(-24 * time.Hour)

	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	tag, err := tx.Exec(ctx, `
		DELETE FROM time_blockers
		WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
		   OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
		   OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
		   OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
		   OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
		   OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
	`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
	if err != nil {
		return 0, err
	}

	return int(tag.RowsAffected()), tx.Commit(ctx)
}

// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	var totalRows int

	tag, err := tx.Exec(ctx, `
	UPDATE users SET
		n_first_name = 'Guest',
		n_last_name = 'Anonymized',
		email = 'anon-' || id || '@anon.invalid',
		phone = '000000000000',
		date_of_birth = '1900-01-01',
		profile_pic_url = NULL,
		referral_code = NULL,
		notes = NULL,
		data_retention_consent = FALSE,
		updated_at = NOW()
		WHERE account_role = 'guest'
		  AND NOT EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id AND status IN ('pending', 'confirmed'))
		  AND EXISTS (SELECT 1 FROM bookings WHERE user_id = users.id GROUP BY user_id HAVING MAX(start_time) < NOW() - INTERVAL '6 months')
	`)
	if err != nil {
		return 0, err
	}
	totalRows += int(tag.RowsAffected())

	// Anonymize patch test records for stale guests (medical-adjacent PII)
	tag, err = tx.Exec(ctx, `
		UPDATE user_patch_tests SET user_id = NULL
		WHERE user_id IN (
			SELECT id FROM users
			WHERE account_role = 'guest'
			  AND n_first_name = 'Guest'
			  AND n_last_name = 'Anonymized'
		)
	`)
	if err != nil {
		return 0, err
	}
	totalRows += int(tag.RowsAffected())

	// Anonymize referral relationships for stale guests
	tag, err = tx.Exec(ctx, `
		UPDATE user_referrals SET referrer_id = NULL
		WHERE referrer_id IN (
			SELECT id FROM users
			WHERE account_role = 'guest'
			  AND n_first_name = 'Guest'
			  AND n_last_name = 'Anonymized'
		)
	`)
	if err != nil {
		return 0, err
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		UPDATE user_referrals SET referred_id = NULL
		WHERE referred_id IN (
			SELECT id FROM users
			WHERE account_role = 'guest'
			  AND n_first_name = 'Guest'
			  AND n_last_name = 'Anonymized'
		)
	`)
	if err != nil {
		return 0, err
	}
	totalRows += int(tag.RowsAffected())

	// Anonymize admin notification references for stale guests
	tag, err = tx.Exec(ctx, `
		UPDATE admin_notifications SET user_id = NULL
		WHERE user_id IN (
			SELECT id FROM users
			WHERE account_role = 'guest'
			  AND n_first_name = 'Guest'
			  AND n_last_name = 'Anonymized'
		)
	`)
	if err != nil {
		return 0, err
	}
	totalRows += int(tag.RowsAffected())

	return totalRows, tx.Commit(ctx)
}

func CleanupExpiredLoyaltyRedemptions(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	tag, err := tx.Exec(ctx, `
		DELETE FROM loyalty_redemptions
		WHERE status = 'pending'
		  AND expires_at < NOW()
	`)
	if err != nil {
		return 0, err
	}

	return int(tag.RowsAffected()), tx.Commit(ctx)
}

// CleanupExpiredFinancialRecords aggregates granular payment/refund records
// whose retention period has expired into monthly totals, then deletes them.
// Runs daily at 4am.
//
// Retention policy — dual threshold, keeps the record until the later of:
//   - 7 years from payment creation (HMRC requirement)
//   - 1 year from account GDPR anonymisation/deletion (Limitation Act 1980
//     England & Wales buffer — records kept for lawsuit defence)
//
// Active users are never deleted — their records stay accessible. Only records
// belonging to users who have been fully GDPR-deleted or anonymised and whose
// 1-year post-deletion buffer has passed are aggregated and removed.
//
// The function is idempotent — running it twice produces the same result.

const retentionFilter = `AND (
    b.user_id IS NULL
    OR u.id IS NULL
    OR (
        u.account_role = 'guest'
        AND (u.email LIKE 'anon-%@anon.invalid' OR u.email LIKE 'deleted+%@deleted.invalid')
        AND u.updated_at < NOW() - INTERVAL '1 year'
    )
)`

func CleanupExpiredFinancialRecords(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	var totalRows int

	tag, err := tx.Exec(ctx, `
		INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, total_vat_amount, total_net_amount, booking_count)
		SELECT
			DATE_TRUNC('month', p.created_at)::date AS month,
			COALESCE(SUM(p.amount), 0) AS total_payments,
			COALESCE(SUM(p.fees), 0) AS total_square_fees,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'cash'), 0) AS total_cash,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'online_square'), 0) AS total_online,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'in_person_card'), 0) AS total_in_person,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'discount'), 0) AS total_discounts,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_method = 'giftcard'), 0) AS total_giftcard,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'tip'), 0) AS total_tips,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'deposit'), 0) AS total_deposits,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'balance'), 0) AS total_balances,
			COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'partial'), 0) AS total_partials,
			COALESCE(SUM(p.vat_amount), 0) AS total_vat_amount,
			COALESCE(SUM(p.net_amount), 0) AS total_net_amount,
			COALESCE(COUNT(DISTINCT p.booking_id), 0) AS booking_count
		FROM payments p
		LEFT JOIN bookings b ON p.booking_id = b.id
		LEFT JOIN users u ON b.user_id = u.id
		WHERE p.created_at < NOW() - INTERVAL '7 years'
		` + retentionFilter + `
		GROUP BY DATE_TRUNC('month', p.created_at)::date
		ON CONFLICT (month) DO UPDATE SET
			total_payments     = financial_aggregates.total_payments + EXCLUDED.total_payments,
			total_square_fees  = financial_aggregates.total_square_fees + EXCLUDED.total_square_fees,
			total_cash         = financial_aggregates.total_cash + EXCLUDED.total_cash,
			total_online       = financial_aggregates.total_online + EXCLUDED.total_online,
			total_in_person    = financial_aggregates.total_in_person + EXCLUDED.total_in_person,
			total_discounts    = financial_aggregates.total_discounts + EXCLUDED.total_discounts,
			total_giftcard     = financial_aggregates.total_giftcard + EXCLUDED.total_giftcard,
			total_tips         = financial_aggregates.total_tips + EXCLUDED.total_tips,
			total_deposits     = financial_aggregates.total_deposits + EXCLUDED.total_deposits,
			total_balances     = financial_aggregates.total_balances + EXCLUDED.total_balances,
			total_partials     = financial_aggregates.total_partials + EXCLUDED.total_partials,
			total_vat_amount   = financial_aggregates.total_vat_amount + EXCLUDED.total_vat_amount,
			total_net_amount   = financial_aggregates.total_net_amount + EXCLUDED.total_net_amount,
			booking_count      = financial_aggregates.booking_count + EXCLUDED.booking_count
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to aggregate expired payments: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		INSERT INTO financial_aggregates (month, total_refunds)
		SELECT
			DATE_TRUNC('month', r.created_at)::date AS month,
			COALESCE(SUM(r.amount), 0) AS total_refunds
		FROM refunds r
		JOIN payments p ON r.payment_id = p.id
		LEFT JOIN bookings b ON p.booking_id = b.id
		LEFT JOIN users u ON b.user_id = u.id
		WHERE p.created_at < NOW() - INTERVAL '7 years'
		` + retentionFilter + `
		GROUP BY DATE_TRUNC('month', r.created_at)::date
		ON CONFLICT (month) DO UPDATE SET
			total_refunds = financial_aggregates.total_refunds + EXCLUDED.total_refunds
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to aggregate expired refunds: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		DELETE FROM payments p
		USING bookings b
		LEFT JOIN users u ON b.user_id = u.id
		WHERE p.booking_id = b.id
		  AND p.created_at < NOW() - INTERVAL '7 years'
		` + retentionFilter + `
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to delete expired payments: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		DELETE FROM refunds r
		USING payments p
		LEFT JOIN bookings b ON p.booking_id = b.id
		LEFT JOIN users u ON b.user_id = u.id
		WHERE r.payment_id = p.id
		  AND p.created_at < NOW() - INTERVAL '7 years'
		` + retentionFilter + `
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to delete expired refunds: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	return totalRows, tx.Commit(ctx)
}

// CleanupExpiredDeposits marks bookings as pending_release when the deposit
// deadline (24h before start_time) has passed without payment. The booking is
// kept alive in a vulnerable state — the slot becomes available for others to
// book, and if another booking claims it, the original is cancelled with
// deposit forfeited.
//
// If the deposit IS paid after the deadline but before the appointment, the
// booking flips back to confirmed.
func CleanupExpiredDeposits(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	// Collect all evicted booking IDs from both updates so we can notify
	// and clean up in one pass instead of re-scanning via updated_at = NOW().
	type evictedBooking struct{ id, userID string }
	var evicted []evictedBooking

	// Confirmed bookings past deposit deadline → pending_release
	rows, err := tx.Query(ctx, `
		UPDATE bookings
		SET status = 'pending_release', updated_at = NOW()
		WHERE deposit_required = true
		  AND status = 'confirmed'
		  AND start_time - INTERVAL '24 hours' < NOW()
		  AND NOT EXISTS (
			  SELECT 1 FROM payments p
			  WHERE p.booking_id = bookings.id
			    AND p.status = 'completed'
			    AND p.created_at < bookings.start_time
		  )
		RETURNING id, user_id
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to expire confirmed booking deposits: %w", err)
	}
	for rows.Next() {
		var b evictedBooking
		if err := rows.Scan(&b.id, &b.userID); err != nil {
			rows.Close()
			return 0, fmt.Errorf("failed to scan evicted confirmed booking: %w", err)
		}
		evicted = append(evicted, b)
	}
	rows.Close()

	// Pending bookings past deposit deadline → pending_release
	rows, err = tx.Query(ctx, `
		UPDATE bookings
		SET status = 'pending_release', updated_at = NOW()
		WHERE deposit_required = true
		  AND status = 'pending'
		  AND start_time - INTERVAL '24 hours' < NOW()
		  AND NOT EXISTS (
			  SELECT 1 FROM payments p
			  WHERE p.booking_id = bookings.id
			    AND p.status = 'completed'
			    AND p.created_at < bookings.start_time
		  )
		RETURNING id, user_id
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to expire pending expired bookings: %w", err)
	}
	for rows.Next() {
		var b evictedBooking
		if err := rows.Scan(&b.id, &b.userID); err != nil {
			rows.Close()
			return 0, fmt.Errorf("failed to scan evicted pending booking: %w", err)
		}
		evicted = append(evicted, b)
	}
	rows.Close()

	if len(evicted) == 0 {
		return 0, tx.Commit(ctx)
	}
	n := len(evicted)

	// Create admin notifications and clean up time_blockers for evicted bookings.
	ids := make([]string, len(evicted))
	userIDs := make([]string, len(evicted))
	for i, b := range evicted {
		ids[i] = b.id
		userIDs[i] = b.userID
	}

	_, err = tx.Exec(ctx, `
		INSERT INTO admin_notifications (reason, booking_id, user_id)
		SELECT 'deposit_not_paid_by_deadline', unnest($1::text[]), unnest($2::text[])
		WHERE NOT EXISTS (
			SELECT 1 FROM admin_notifications an
			WHERE an.booking_id = ANY($1) AND an.reason = 'deposit_not_paid_by_deadline'
		)
	`, ids, userIDs)
	if err != nil {
		return 0, fmt.Errorf("failed to create pending_release notifications: %w", err)
	}

	_, err = tx.Exec(ctx, `
		DELETE FROM time_blockers tb
		USING unnest($1::text[]) AS evicted_ids(id)
		WHERE tb.created_by = ANY($2::text[])
		   OR tb.description ILIKE ANY(
			   SELECT 'RESERVATION:user:' || u || '%'
			   FROM unnest($2::text[]) AS u
		   )
	`, ids, userIDs)
	if err != nil {
		return 0, fmt.Errorf("failed to cleanup time_blockers for pending_release bookings: %w", err)
	}

	return n, tx.Commit(ctx)
}

// CleanupExpiredGiftCards expires gift cards unused for 24 months (rolling expiry).
//
// Legal basis:
// - UK Consumer Rights Act 2015: Expiry terms must be "fair and transparent"
// - CMA guidance: 24 months is industry standard (John Lewis, M&S, Sainsbury's)
// - Under 12 months risks being challenged as unfair contract term
//
// This function:
// 1. Finds unredeemed cards (redeemed_by IS NULL) unused for 24+ months
// 2. Inserts into gift_card_expired_balances for recovery claims
// 3. Sets amount_remaining to 0
// 4. Records transaction in gift_card_transactions
//
// Once redeemed to an account, the balance doesn't expire (but the account can
// be deleted after idle time per GDPR - see CleanupIdleAccounts).
//
// Pre-expiry email warnings are intentionally omitted: gift cards are unowned
// (bought as gifts, change hands) until redeemed to an account. After redemption,
// the balance is covered by CleanupIdleAccounts warnings.
func CleanupExpiredGiftCards(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	rows, err := tx.Query(ctx, `
		SELECT id, amount_remaining
		FROM gift_cards
		WHERE redeemed_by IS NULL
		  AND amount_remaining > 0
		  AND last_used_at < NOW() - INTERVAL '24 months'
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to query expired gift cards: %w", err)
	}
	defer rows.Close()

	var expiredCards []struct {
		id      string
		balance float64
	}

	for rows.Next() {
		var card struct {
			id      string
			balance float64
		}
		if err := rows.Scan(&card.id, &card.balance); err != nil {
			return 0, fmt.Errorf("failed to scan expired gift card: %w", err)
		}
		expiredCards = append(expiredCards, card)
	}

	if len(expiredCards) > 0 {
		ids := make([]string, len(expiredCards))
		balances := make([]float64, len(expiredCards))
		for i, c := range expiredCards {
			ids[i] = c.id
			balances[i] = c.balance
		}

		if _, err = tx.Exec(ctx, `
			INSERT INTO gift_card_expired_balances (original_balance, expired_at)
			SELECT unnest($1::numeric[]), NOW()
		`, balances); err != nil {
			return 0, fmt.Errorf("failed to batch insert expired balances: %w", err)
		}

		if _, err = tx.Exec(ctx, `
			UPDATE gift_cards
			SET amount_remaining = 0, last_used_at = NOW()
			WHERE id = ANY($1)
		`, ids); err != nil {
			return 0, fmt.Errorf("failed to batch zero expired cards: %w", err)
		}

		if _, err = tx.Exec(ctx, `
			INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, notes)
			SELECT unnest($1::text[]), 'expire', unnest($2::numeric[]), 'system', 'card expired after 24 months unused'
		`, ids, balances); err != nil {
			return 0, fmt.Errorf("failed to batch insert expire transactions: %w", err)
		}
	}

	return len(expiredCards), tx.Commit(ctx)
}

// CleanupIdleAccounts deletes user accounts that have been idle for extended periods.
//
// Legal basis (GDPR Article 5(1)(e) - Storage Limitation):
//   - No money: 2 years idle (legitimate interest in customer relationship weakens)
//   - With money: 5 years idle (Scottish prescriptive period for contract claims,
//     Prescription and Limitation (Scotland) Act 1973 s.6)
//
// When an account with balance is deleted:
// 1. Balance moves to gift_card_expired_balances for recovery claims
// 2. Account is anonymized (PII removed, account ID retained)
// 3. User can recover balance with account ID (no deadline imposed)
//
// This transforms "forfeiture" into "dormancy", reducing legal risk from 40-50%
// to 10-20% per CMA unfair terms analysis.
//
// TODO: Email Integration
// Before deleting accounts, send warning emails:
// - 18 months idle (no balance): "Your account will be deleted in 6 months due to inactivity"
// - 23 months idle (no balance): "Your account will be deleted in 30 days"
// - 4 years idle (with balance): "Your account will be deleted in 1 year. Balance: £X"
// - 59 months idle (with balance): "Your account will be deleted in 30 days. Balance: £X"
// Include account ID in all emails for future recovery claims.
// Query: SELECT u.id, u.email, u.last_login_at, COALESCE(b.balance, 0) as balance
//
//	FROM users u LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
//	WHERE u.account_role NOT IN ('admin', 'guest')
//	AND u.last_login_at < NOW() - INTERVAL '18 months'
//
// Store sent warnings in account_email_warnings table to avoid duplicates.
func CleanupIdleAccounts(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	rowsWithBalance, err := tx.Query(ctx, `
		SELECT u.id, COALESCE(b.balance, 0) as balance
		FROM users u
		LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
		WHERE u.account_role != 'admin'
		  AND u.account_role != 'guest'
		  AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '5 years')
		  AND (b.balance IS NULL OR b.balance > 0)
		  AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to query idle accounts with balance: %w", err)
	}
	defer rowsWithBalance.Close()

	var accountsWithBalance []struct {
		id      string
		balance float64
	}

	for rowsWithBalance.Next() {
		var acc struct {
			id      string
			balance float64
		}
		if err := rowsWithBalance.Scan(&acc.id, &acc.balance); err != nil {
			return 0, fmt.Errorf("failed to scan idle account with balance: %w", err)
		}
		accountsWithBalance = append(accountsWithBalance, acc)
	}

	if len(accountsWithBalance) > 0 {
		ids := make([]string, len(accountsWithBalance))
		balances := make([]float64, len(accountsWithBalance))
		for i, a := range accountsWithBalance {
			ids[i] = a.id
			balances[i] = a.balance
		}

		if _, err = tx.Exec(ctx, `
			INSERT INTO gift_card_expired_balances (account_id, original_balance, expired_at)
			SELECT unnest($1::text[]), unnest($2::numeric[]), NOW()
		`, ids, balances); err != nil {
			return 0, fmt.Errorf("failed to batch insert expired balances: %w", err)
		}

		if _, err = tx.Exec(ctx, `
			UPDATE user_giftcard_balances
			SET balance = 0, updated_at = NOW()
			WHERE user_id = ANY($1)
		`, ids); err != nil {
			return 0, fmt.Errorf("failed to batch zero account balances: %w", err)
		}

		if _, err = tx.Exec(ctx, `
			SELECT anonymize_user(unnest($1::text[]))
		`, ids); err != nil {
			return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err)
		}
	}

	rowsNoBalance, err := tx.Query(ctx, `
		SELECT u.id
		FROM users u
		LEFT JOIN user_giftcard_balances b ON u.id = b.user_id
		WHERE u.account_role != 'admin'
		  AND u.account_role != 'guest'
		  AND (u.last_login_at IS NULL OR u.last_login_at < NOW() - INTERVAL '2 years')
		  AND (b.balance IS NULL OR b.balance = 0)
		  AND NOT (u.email LIKE 'deleted+%@deleted.invalid' OR u.email LIKE 'anon-%@anon.invalid')
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to query idle accounts without balance: %w", err)
	}
	defer rowsNoBalance.Close()

	var accountsNoBalance []string
	for rowsNoBalance.Next() {
		var id string
		if err := rowsNoBalance.Scan(&id); err != nil {
			return 0, fmt.Errorf("failed to scan idle account without balance: %w", err)
		}
		accountsNoBalance = append(accountsNoBalance, id)
	}
	rowsNoBalance.Close()

	if _, err = tx.Exec(ctx, `
		SELECT anonymize_user(unnest($1::text[]))
	`, accountsNoBalance); err != nil {
		return 0, fmt.Errorf("failed to anonymize idle accounts: %w", err)
	}

	return len(accountsWithBalance) + len(accountsNoBalance), tx.Commit(ctx)
}

// CleanupOldIdempotencyKeys clears idempotency keys from bookings, payments, and
// till_sales that are older than 24 hours and no longer pending. This prevents
// unbounded table growth while preserving keys for recent in-flight requests.
func CleanupOldIdempotencyKeys(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	var totalRows int

	tag, err := tx.Exec(ctx, `
		UPDATE bookings
		SET idempotency_key = NULL
		WHERE idempotency_key IS NOT NULL
		  AND created_at < NOW() - INTERVAL '24 hours'
		  AND status != 'pending'
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to cleanup booking idempotency keys: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		UPDATE payments
		SET idempotency_key = NULL
		WHERE idempotency_key IS NOT NULL
		  AND created_at < NOW() - INTERVAL '24 hours'
		  AND status != 'pending'
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to cleanup payment idempotency keys: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	tag, err = tx.Exec(ctx, `
		UPDATE till_sales
		SET idempotency_key = NULL
		WHERE idempotency_key IS NOT NULL
		  AND created_at < NOW() - INTERVAL '24 hours'
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err)
	}
	totalRows += int(tag.RowsAffected())

	return totalRows, tx.Commit(ctx)
}

// CleanupOldNameHistory removes name_history entries older than 6 months.
// WHY: GDPR requires data minimization — name change history doesn't need
// to be retained indefinitely. 6 months provides a reasonable window for
// displaying former names on booking receipts and admin views.
func CleanupOldNameHistory(ctx context.Context) (int, error) {
	tx, err := db.Conn.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("failed to begin transaction: %w", err)
	}
	defer func() {
    if err := tx.Rollback(ctx); err != nil {
        slog.Error("failed to rollback transaction", "err", err)
    }
}()

	tag, err := tx.Exec(ctx, `
		DELETE FROM name_history
		WHERE changed_at < NOW() - INTERVAL '6 months'
	`)
	if err != nil {
		return 0, fmt.Errorf("failed to cleanup old name history: %w", err)
	}

	return int(tag.RowsAffected()), tx.Commit(ctx)
}
