Ctrl K
ComunidadGuías…

internal/repository/clickhouse/aggregators.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package clickhouse provides ClickHouse-specific aggregators for usage data.
//
// Custom Monthly Billing Periods
// ==============================
//
// This package supports custom monthly billing periods through the BillingAnchor parameter.
// This allows usage data to be aggregated by custom monthly periods (e.g., 5th to 5th of each month)
// instead of standard calendar months (1st to 1st of each month).
//
// How it works:
// 1. When WindowSize = "MONTH" and BillingAnchor is provided, events are grouped by custom monthly periods
// 2. The BillingAnchor timestamp defines the reference day for monthly periods (time is ignored)
// 3. Day-level granularity is used for simplicity and predictability
// 4. All other window sizes (DAY, HOUR, WEEK, etc.) ignore BillingAnchor and use standard windows
//
// Example:
//   BillingAnchor = 2024-03-05 (any time on March 5th)
//   - March period: 2024-03-05 to 2024-04-05
//   - April period: 2024-04-05 to 2024-05-05
//
// Use cases:
// - Subscription billing that doesn't align with calendar months
// - Customer-specific billing cycles (e.g., signed up on 15th)
// - Multi-tenant systems with different billing anchor dates
// - Custom business cycles (fiscal months, quarterly periods)
//
// Implementation:
// The custom monthly logic generates ClickHouse expressions that:
// 1. Shift timestamps by the day offset from the billing anchor
// 2. Apply toStartOfMonth() to get calendar month boundaries
// 3. Shift back by the same day offset to create custom monthly periods
//
// This ensures that events are correctly grouped into the appropriate billing periods
// using day-level granularity for better predictability and user understanding.
//
// Query Parameterization
// =======================
//
// All queries built in this file use `?` positional placeholders for any value that
// originates from user/API input (filter property names/values, customer IDs, event
// name, timestamps). Each query-building function returns (query string, []interface{}
// args) — the args slice must be passed to the ClickHouse driver's Query(ctx, query,
// args...) call in the same order the placeholders appear in the concatenated query
// string, since ClickHouse binds positionally. This mirrors the pattern already used in
// meter_usage_query_builder.go.
//
// Two inputs cannot be bound and are instead validated then interpolated, because they
// are used as SQL identifiers rather than values:
//   - timezone: validated by normalizeCHTimezone (constrained to resolvable IANA names
//     via time.LoadLocation) before it is interpolated into the window expressions.
//   - group-by property: validated by validateGroupByProperty (strict allow-list) before
//     it is interpolated as the JSONExtractString key.

package clickhouse

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/flexprice/flexprice/internal/domain/events"
	"github.com/flexprice/flexprice/internal/types"
	"github.com/samber/lo"
	"github.com/shopspring/decimal"
)

func GetAggregator(aggregationType types.AggregationType) events.Aggregator {
	switch aggregationType {
	case types.AggregationCount:
		return &CountAggregator{}
	case types.AggregationSum:
		return &SumAggregator{}
	case types.AggregationAvg:
		return &AvgAggregator{}
	case types.AggregationCountUnique:
		return &CountUniqueAggregator{}
	case types.AggregationLatest:
		return &LatestAggregator{}
	case types.AggregationSumWithMultiplier:
		return &SumWithMultiAggregator{}
	case types.AggregationMax:
		return &MaxAggregator{}
	case types.AggregationWeightedSum:
		return &WeightedSumAggregator{}
	}
	return nil
}

func getDeduplicationKey() string {
	return "id"
}

// buildUsageEventCustomerFilters returns PREWHERE fragments (with `?` placeholders) for
// external_customer_id and FlexPrice customer_id, plus the args to bind against them, in
// the order the two returned fragments are concatenated into the final query. Params may
// be nil. External merges ExternalCustomerID and ExternalCustomerIDs (deduped); internal
// uses CustomerID only.
func buildUsageEventCustomerFilters(params *events.UsageParams) (externalCustomerFilter string, customerFilter string, args []interface{}) {
	if params == nil {
		return "", "", nil
	}

	extIDs := make([]string, 0)
	if params.ExternalCustomerID != "" {
		extIDs = append(extIDs, params.ExternalCustomerID)
	}
	if len(params.ExternalCustomerIDs) > 0 {
		extIDs = append(extIDs, params.ExternalCustomerIDs...)
	}
	extUnique := lo.Uniq(extIDs)
	switch len(extUnique) {
	case 0:
		externalCustomerFilter = ""
	case 1:
		externalCustomerFilter = "AND external_customer_id = ?"
		args = append(args, extUnique[0])
	default:
		placeholders := make([]string, len(extUnique))
		for i := range extUnique {
			placeholders[i] = "?"
		}
		externalCustomerFilter = fmt.Sprintf("AND external_customer_id IN (%s)", strings.Join(placeholders, ", "))
		for _, id := range extUnique {
			args = append(args, id)
		}
	}

	if params.CustomerID != "" {
		customerFilter = "AND customer_id = ?"
		args = append(args, params.CustomerID)
	}
	return externalCustomerFilter, customerFilter, args
}

// normalizeCHTimezone returns a valid IANA timezone name for ClickHouse
// toStartOf* functions, defaulting to UTC for empty, "UTC", or unresolvable
// values. Passing an invalid name straight to ClickHouse would error the query,
// so this is the single guard shared by both window formatters. Since the return
// value is validated against time.LoadLocation, it is safe to interpolate directly.
func normalizeCHTimezone(tz string) string {
	if tz == "" || tz == types.DefaultTimezone {
		return types.DefaultTimezone
	}
	if _, err := time.LoadLocation(tz); err != nil {
		return types.DefaultTimezone
	}
	return tz
}

// formatWindowSize returns the ClickHouse expression that buckets `timestamp`
// into the given window in the given timezone. "UTC" is a valid ClickHouse
// timezone argument and yields identical results to omitting it, so the tz is
// always passed — there is no UTC special-casing. The tz value has already been
// validated by normalizeCHTimezone (constrained to resolvable IANA names via
// time.LoadLocation), so it is safe to interpolate directly here rather than bind
// as a query arg — these window expressions are used inside larger fmt.Sprintf
// templates that are themselves fully parameterized for user-controlled values.
func formatWindowSize(windowSize types.WindowSize, tz string) string {
	if windowSize == "" {
		return ""
	}
	// Minute buckets are timezone-invariant (all real offsets are whole minutes).
	if windowSize == types.WindowSizeMinute {
		return "toStartOfMinute(timestamp)"
	}

	tz = normalizeCHTimezone(tz)
	switch windowSize {
	case types.WindowSize15Min:
		return fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL 15 MINUTE, '%s')", tz)
	case types.WindowSize30Min:
		return fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL 30 MINUTE, '%s')", tz)
	case types.WindowSize3Hour:
		return fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL 3 HOUR, '%s')", tz)
	case types.WindowSize6Hour:
		return fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL 6 HOUR, '%s')", tz)
	case types.WindowSize12Hour:
		return fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL 12 HOUR, '%s')", tz)
	case types.WindowSizeHour:
		return fmt.Sprintf("toStartOfHour(timestamp, '%s')", tz)
	case types.WindowSizeWeek:
		return fmt.Sprintf("toStartOfWeek(timestamp, 0, '%s')", tz)
	case types.WindowSizeMonth:
		return fmt.Sprintf("toStartOfMonth(timestamp, '%s')", tz)
	case types.WindowSizeDay:
		return fmt.Sprintf("toStartOfDay(timestamp, '%s')", tz)
	default:
		return fmt.Sprintf("toStartOfDay(timestamp, '%s')", tz)
	}
}

// formatWindowSizeWithBillingAnchor formats window size with custom billing anchor for monthly periods.
//
// This function handles custom billing periods for monthly aggregations, allowing events to be grouped
// by custom monthly periods (e.g., 5th to 5th of each month) instead of calendar months (1st to 1st).
//
// Parameters:
//   - windowSize: The aggregation window size (MONTH, DAY, HOUR, etc.)
//   - billingAnchor: The reference timestamp for custom monthly periods (only used for MONTH window size)
//
// Behavior by window size:
//   - MONTH + billingAnchor: Creates custom monthly periods using day-level granularity
//   - MONTH + nil: Falls back to standard calendar months (toStartOfMonth)
//   - DAY/HOUR/WEEK/etc: Ignores billing anchor, uses standard window functions
//
// Example: billingAnchor = 2024-03-05 (any time on March 5th)
//   - Events from March 5 to April 5 → March billing period
//   - Events from April 5 to May 5 → April billing period
//
// The generated ClickHouse expression shifts timestamps by the day offset,
// applies toStartOfMonth(), then shifts back to create the correct billing periods.
// This uses day-level granularity for simplicity and predictability.
func formatWindowSizeWithBillingAnchor(windowSize types.WindowSize, billingAnchor *time.Time, tz string) string {
	if windowSize == types.WindowSizeMonth && billingAnchor != nil {
		// Extract only the day component from billing anchor for simplicity
		anchorDay := billingAnchor.Day()
		tz = normalizeCHTimezone(tz)

		// Custom monthly window anchored on anchorDay, computed in the customer's
		// timezone. Shift by the day offset, snap to month start, shift back.
		// "UTC" is a valid tz argument and reproduces the legacy (UTC) result.
		return fmt.Sprintf("addDays(toStartOfMonth(addDays(toTimezone(timestamp, '%s'), -%d), '%s'), %d)", tz, anchorDay-1, tz, anchorDay-1)
	}

	// Fall back to standard window size formatting
	return formatWindowSize(windowSize, tz)
}

// buildFilterConditions builds JSONExtractString filter fragments using positional `?`
// placeholders for both the property name and its value(s) — both are user-controlled
// (request body `filters` map) and must never be interpolated into the query string.
// Returns the WHERE-fragment (e.g. "AND JSONExtractString(properties, ?) IN (?,?) AND ...")
// and the args to bind, in the exact order the placeholders appear.
func buildFilterConditions(filters map[string][]string) (string, []interface{}) {
	if len(filters) == 0 {
		return "", nil
	}

	var conditions []string
	var args []interface{}
	for key, values := range filters {
		if len(values) == 0 {
			continue
		}

		placeholders := make([]string, len(values))
		for i := range values {
			placeholders[i] = "?"
		}

		conditions = append(conditions, fmt.Sprintf(
			"JSONExtractString(properties, ?) IN (%s)",
			strings.Join(placeholders, ","),
		))
		args = append(args, key)
		for _, v := range values {
			args = append(args, v)
		}
	}

	if len(conditions) == 0 {
		return "", nil
	}

	return "AND " + strings.Join(conditions, " AND "), args
}

// buildTimeConditions builds the time-range WHERE fragment using `?` placeholders.
func buildTimeConditions(params *events.UsageParams) (string, []interface{}) {
	conditions, args := parseTimeConditions(params)

	if len(conditions) == 0 {
		return "", nil
	}

	return "AND " + strings.Join(conditions, " AND "), args
}

// appendArgs concatenates arg groups onto base in order — a small helper to
// collapse the repeated `args = append(args, xArgs...)` chain that follows
// every query-building function's args slice construction in this file.
func appendArgs(base []interface{}, groups ...[]interface{}) []interface{} {
	for _, g := range groups {
		base = append(base, g...)
	}
	return base
}

// parseTimeConditions binds start/end time.Time values directly to `?` placeholders.
// The clickhouse-go driver's formatTime emits an explicit `'UTC'` third arg when the
// value's location doesn't match the server's, so the timestamp is compared as a UTC
// instant regardless of server timezone. Formatting to a string first and wrapping in
// toDateTime64('...', 3) omits the timezone and lets the server reinterpret the value
// in its local tz — the exact bug this replaces.
func parseTimeConditions(params *events.UsageParams) ([]string, []interface{}) {
	var conditions []string
	var args []interface{}

	if !params.StartTime.IsZero() {
		conditions = append(conditions, "timestamp >= ?")
		args = append(args, params.StartTime.UTC())
	}

	if !params.EndTime.IsZero() {
		conditions = append(conditions, "timestamp < ?")
		args = append(args, params.EndTime.UTC())
	}

	return conditions, args
}

// SumAggregator implements sum aggregation
type SumAggregator struct{}

func (a *SumAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	// If bucket_size is specified, use windowed aggregation
	if params.BucketSize != "" {
		return a.getWindowedQuery(ctx, params)
	}
	// Otherwise use simple SUM aggregation
	return a.getNonWindowedQuery(ctx, params)
}

func (a *SumAggregator) getNonWindowedQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""
	windowGroupBy := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
		windowGroupBy = ", window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        SELECT
            %s sum(value) as total
        FROM (
            SELECT
                %s anyLast(JSONExtractFloat(assumeNotNull(properties), ?)) as value
            FROM events
            PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
                %s
                %s
            GROUP BY %s %s
        )
        %s
    `,
		selectClause,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		getDeduplicationKey(),
		windowGroupBy,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *SumAggregator) getWindowedQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	bucketWindow := formatWindowSizeWithBillingAnchor(params.BucketSize, params.BillingAnchor, params.Timezone)

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	// Get sum values per bucket, return each bucket's sum separately
	query := fmt.Sprintf(`
		WITH bucket_sums AS (
			SELECT
				%s as bucket_start,
				sum(JSONExtractFloat(assumeNotNull(properties), ?)) as bucket_sum
			FROM events FINAL
			PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
				%s
				%s
			GROUP BY bucket_start
			ORDER BY bucket_start
		)
		SELECT
			(SELECT sum(bucket_sum) FROM bucket_sums) as total,
			bucket_start as timestamp,
			bucket_sum as value
		FROM bucket_sums
		ORDER BY bucket_start
	`,
		bucketWindow,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *SumAggregator) GetType() types.AggregationType {
	return types.AggregationSum
}

// CountAggregator implements count aggregation
type CountAggregator struct{}

func (a *CountAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	groupByClause := ""

	if windowSize != "" {
		selectClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        SELECT
            %s count(DISTINCT %s) as total
        FROM events
        PREWHERE tenant_id = ?
			AND environment_id = ?
			AND event_name = ?
			%s
			%s
            %s
            %s
        %s
    `,
		selectClause,
		getDeduplicationKey(),
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		groupByClause)

	args := appendArgs([]interface{}{types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *CountAggregator) GetType() types.AggregationType {
	return types.AggregationCount
}

// CountUniqueAggregator implements count unique aggregation
type CountUniqueAggregator struct{}

func (a *CountUniqueAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""
	windowGroupBy := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
		windowGroupBy = ", window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        SELECT
            %s count(DISTINCT property_value) as total
        FROM (
            SELECT
                %s JSONExtractString(assumeNotNull(properties), ?) as property_value
            FROM events
            PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
                %s
                %s
            GROUP BY %s, property_value %s
        )
        %s
    `,
		selectClause,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		getDeduplicationKey(),
		windowGroupBy,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *CountUniqueAggregator) GetType() types.AggregationType {
	return types.AggregationCountUnique
}

// AvgAggregator implements avg aggregation
type AvgAggregator struct{}

func (a *AvgAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""
	windowGroupBy := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
		windowGroupBy = ", window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        SELECT
            %s avg(value) as total
        FROM (
            SELECT
                %s anyLast(JSONExtractFloat(assumeNotNull(properties), ?)) as value
            FROM events
            PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
				%s
                %s
            GROUP BY %s %s
        )
        %s
    `,
		selectClause,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		getDeduplicationKey(),
		windowGroupBy,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *AvgAggregator) GetType() types.AggregationType {
	return types.AggregationAvg
}

// LatestAggregator implements latest value aggregation
type LatestAggregator struct{}

func (a *LatestAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	windowClause := ""
	groupByClause := ""

	if windowSize != "" {
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        SELECT
            %s argMax(JSONExtractFloat(assumeNotNull(properties), ?), timestamp) as total
        FROM
			events
			PREWHERE tenant_id = ?
                AND environment_id = ?
                AND event_name = ?
                %s
                %s
                %s
                %s
        %s
    `,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *LatestAggregator) GetType() types.AggregationType {
	return types.AggregationLatest
}

// SumWithMultiAggregator implements sum with multiplier aggregation
type SumWithMultiAggregator struct{}

func (a *SumWithMultiAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""
	windowGroupBy := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
		windowGroupBy = ", window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	// Multiplier is a decimal.Decimal value computed server-side (not raw user string
	// input) — safe to interpolate as a numeric literal via .String(), same as before.
	multiplier := decimal.NewFromInt(1)
	if params.Multiplier != nil {
		multiplier = *params.Multiplier
	}

	query := fmt.Sprintf(`
        SELECT
            %s (sum(value) * %s) as total
        FROM (
            SELECT
                %s anyLast(JSONExtractFloat(assumeNotNull(properties), ?)) as value
            FROM events
            PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
                %s
                %s
            GROUP BY %s %s
        )
        %s
    `,
		selectClause,
		multiplier.String(),
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		getDeduplicationKey(),
		windowGroupBy,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *SumWithMultiAggregator) GetType() types.AggregationType {
	return types.AggregationSumWithMultiplier
}

// MaxAggregator implements max aggregation
type MaxAggregator struct{}

func (a *MaxAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	// If bucket_size is specified, use windowed aggregation
	if params.BucketSize != "" {
		return a.getWindowedQuery(ctx, params)
	}
	// Otherwise use simple MAX aggregation
	return a.getNonWindowedQuery(ctx, params)
}

func (a *MaxAggregator) getNonWindowedQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""
	windowGroupBy := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
		windowGroupBy = ", window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
		SELECT
			%s max(value) as total
		FROM (
			SELECT
				%s anyLast(JSONExtractFloat(assumeNotNull(properties), ?)) as value
			FROM events
			PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
				%s
				%s
			GROUP BY %s %s
		)
		%s
	`,
		selectClause,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		getDeduplicationKey(),
		windowGroupBy,
		groupByClause)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *MaxAggregator) getWindowedQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	bucketWindow := formatWindowSizeWithBillingAnchor(params.BucketSize, params.BillingAnchor, params.Timezone)

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	// When GroupBy has a "properties.X" entry, return per-group rows so tiered pricing
	// can be applied per group (e.g. per KRN). The events-table SQL only supports
	// a single property dim here; multi-dim grouping is on the bucketed-meter path.
	// 1. per_group CTE: max per group per bucket (e.g., MAX per krn per hour)
	// 2. Return each group's value with group_key; total is sum of all group values for backward compat
	if groupByProperty := events.FirstGroupByProperty(params.GroupBy); groupByProperty != "" && validateGroupByProperty(groupByProperty) == nil {
		// groupByProperty has already been validated by validateGroupByProperty
		// (alphanumeric/underscore/dot only), so it is safe to interpolate directly
		// into the JSONExtractString key argument here — it cannot carry SQL metacharacters.
		groupByExpr := fmt.Sprintf("JSONExtractString(assumeNotNull(properties), '%s')", groupByProperty)

		query := fmt.Sprintf(`
			WITH per_group AS (
				SELECT
					%s as bucket_start,
					%s as group_key,
					max(JSONExtractFloat(assumeNotNull(properties), ?)) as group_value
				FROM events FINAL
				PREWHERE tenant_id = ?
					AND environment_id = ?
					AND event_name = ?
					%s
					%s
					%s
					%s
				GROUP BY bucket_start, group_key
			)
			SELECT
				(SELECT sum(group_value) FROM per_group) as total,
				bucket_start as timestamp,
				group_value as value,
				group_key
			FROM per_group
			ORDER BY bucket_start, group_key
		`,
			bucketWindow,
			groupByExpr,
			externalCustomerFilter,
			customerFilter,
			filterConditions,
			timeConditions)

		args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

		return query, args
	}

	// First get max values per bucket, then sum across all buckets
	query := fmt.Sprintf(`
		WITH bucket_maxes AS (
			SELECT
				%s as bucket_start,
				max(JSONExtractFloat(assumeNotNull(properties), ?)) as bucket_max
			FROM events FINAL
			PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
				%s
				%s
			GROUP BY bucket_start
			ORDER BY bucket_start
		)
		SELECT
			(SELECT sum(bucket_max) FROM bucket_maxes) as total,
			bucket_start as timestamp,
			bucket_max as value
		FROM bucket_maxes
		ORDER BY bucket_start
	`,
		bucketWindow,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions)

	args := appendArgs([]interface{}{params.PropertyName, types.GetTenantID(ctx), types.GetEnvironmentID(ctx), params.EventName}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *MaxAggregator) GetType() types.AggregationType {
	return types.AggregationMax
}

// WeightedSumAggregator implements weighted sum aggregation
type WeightedSumAggregator struct{}

func (a *WeightedSumAggregator) GetQuery(ctx context.Context, params *events.UsageParams) (string, []interface{}) {
	windowSize := formatWindowSizeWithBillingAnchor(params.WindowSize, params.BillingAnchor, params.Timezone)
	selectClause := ""
	windowClause := ""
	groupByClause := ""

	if windowSize != "" {
		selectClause = "window_size,"
		windowClause = fmt.Sprintf("%s AS window_size,", windowSize)
		groupByClause = "GROUP BY window_size ORDER BY window_size"
	}

	externalCustomerFilter, customerFilter, customerArgs := buildUsageEventCustomerFilters(params)

	filterConditions, filterArgs := buildFilterConditions(params.Filters)
	timeConditions, timeArgs := buildTimeConditions(params)

	query := fmt.Sprintf(`
        WITH
            ? AS period_start,
            ? AS period_end,
            dateDiff('second', period_start, period_end) AS total_seconds
        SELECT
            %s sum(
                (JSONExtractFloat(assumeNotNull(properties), ?) / nullIf(total_seconds, 0)) *
                dateDiff('second', timestamp, period_end)
            ) AS total
        FROM (
            SELECT
                %s timestamp,
                properties
            FROM events
            PREWHERE tenant_id = ?
				AND environment_id = ?
				AND event_name = ?
				%s
				%s
                %s
                %s
        )
        %s
    `,
		selectClause,
		windowClause,
		externalCustomerFilter,
		customerFilter,
		filterConditions,
		timeConditions,
		groupByClause,
	)

	// Bind period_start/period_end as time.Time so the driver emits an explicit UTC
	// timezone. See parseTimeConditions for the full rationale.
	args := appendArgs([]interface{}{
		params.StartTime.UTC(),
		params.EndTime.UTC(),
		params.PropertyName,
		types.GetTenantID(ctx),
		types.GetEnvironmentID(ctx),
		params.EventName,
	}, customerArgs, filterArgs, timeArgs)

	return query, args
}

func (a *WeightedSumAggregator) GetType() types.AggregationType {
	return types.AggregationWeightedSum
}