/**
 * Aggregation Utilities Module for Drizzle ORM
 *
 * This module provides aggregation functions (count, sum, avg, min, max)
 * for Drizzle ORM, matching Sequelize aggregation capabilities.
 *
 * Features:
 * - Standard aggregations (COUNT, SUM, AVG, MIN, MAX)
 * - DISTINCT count
 * - ARRAY_AGG for array aggregation
 * - Date part extraction (YEAR, MONTH, DAY, etc.)
 * - GROUP BY support
 * - Aggregate with relations
 */
import { sql, count } from 'drizzle-orm';
import { isSafeFieldPath } from './relationPathResolver.js';
/** Valid PostgreSQL date/time parts for EXTRACT — allowlist to block injection. */
const VALID_DATE_PARTS = new Set([
    'CENTURY', 'DAY', 'DECADE', 'DOW', 'DOY', 'EPOCH', 'HOUR', 'ISODOW', 'ISOYEAR',
    'MICROSECONDS', 'MILLENNIUM', 'MILLISECONDS', 'MINUTE', 'MONTH', 'QUARTER',
    'SECOND', 'TIMEZONE', 'TIMEZONE_HOUR', 'TIMEZONE_MINUTE', 'WEEK', 'YEAR',
]);
/**
 * Simple API Error class
 */
class APIError extends Error {
    statusCode;
    constructor(message, statusCode = 500) {
        super(message);
        this.statusCode = statusCode;
        this.name = 'APIError';
    }
}
/**
 * Create aggregate function SQL
 * Uses built-in aggregate functions and sql<type> template tags
 */
export function createAggregateFunction(func, column, ctx) {
    switch (func.toLowerCase()) {
        case 'max':
            // max() returns string for decimal/numeric columns, cast and mapWith for consistent number return
            return sql `max((${column})::numeric)`.mapWith(Number);
        case 'min':
            // min() returns string for decimal/numeric columns, cast and mapWith for consistent number return
            return sql `min((${column})::numeric)`.mapWith(Number);
        case 'avg':
            // avg() returns string by default in PostgreSQL, cast and mapWith for number
            return sql `avg((${column})::numeric)`.mapWith(Number);
        case 'sum':
            // sum() returns string by default in PostgreSQL (especially for decimal columns), cast and mapWith for number
            return sql `sum((${column})::numeric)`.mapWith(Number);
        case 'count':
            return count(column);
        case 'distinct':
            return sql `count(distinct ${column})`.mapWith(Number);
        case 'array_agg':
            // Type depends on the column being aggregated
            return sql `array_agg(${column})`;
        default:
            throw new APIError(`Unsupported aggregate function: ${func}`, 400);
    }
}
/**
 * Process date extraction for GROUP BY
 * Format: "date:year:column" -> EXTRACT(YEAR FROM column)
 */
export function processDateExtraction(field, column) {
    const parts = field.split(':');
    if (parts[0] === 'date' && parts.length >= 2) {
        const dateFunction = parts[1];
        const datePart = dateFunction.toUpperCase();
        // Allowlist the EXTRACT part — it is interpolated via sql.raw.
        if (!VALID_DATE_PARTS.has(datePart)) {
            throw new Error(`Invalid date part: ${dateFunction}`);
        }
        // If column is provided, use it directly
        let extractExpression;
        if (column) {
            extractExpression = sql `extract(${sql.raw(datePart)} from ${column})`.mapWith(Number);
        }
        else {
            // Quote the column name to preserve case sensitivity — validate it first.
            const columnName = parts[2];
            if (!isSafeFieldPath(columnName || '')) {
                throw new Error(`Invalid date extraction column: ${columnName}`);
            }
            const quotedColumn = sql.raw(`"${columnName}"`);
            extractExpression = sql `extract(${sql.raw(datePart)} from ${quotedColumn})`.mapWith(Number);
        }
        return {
            expression: extractExpression,
            alias: `${dateFunction}_${parts[2] || 'date'}`
        };
    }
    return null;
}
/**
 * Build aggregate attributes for SELECT clause
 *
 * @param aggregate - Aggregate mapping (alias -> config)
 * @param groupBy - Array of fields to group by
 * @param ctx - Aggregate context
 * @returns Array of [SQL expression, alias] tuples
 *
 * @example
 * ```typescript
 * const aggregate = {
 *   totalUsers: { function: 'count', field: 'id' },
 *   avgAge: { function: 'avg', field: 'age' }
 * };
 *
 * const groupBy = ['status', 'date:year:createdAt'];
 *
 * const attributes = buildAggregateAttributes(aggregate, groupBy, {
 *   tableName: 'users'
 * });
 *
 * // Use in query:
 * const results = await db
 *   .select({
 *     ...Object.fromEntries(attributes.map(([expr, alias]) => [alias, expr]))
 *   })
 *   .from(usersTable)
 *   .groupBy(...groupByExprs);
 * ```
 */
export function buildAggregateAttributes(aggregate, groupBy = [], ctx) {
    const attributes = [];
    // Add aggregate functions
    for (const [alias, aggregateInfo] of Object.entries(aggregate)) {
        const { function: func, field } = aggregateInfo;
        // Reject any aggregate field whose identifier(s) aren't safe before it can be
        // interpolated into raw SQL. "*" (COUNT(*)) is the only allowed non-identifier.
        if (field !== '*' && !isSafeFieldPath(field)) {
            throw new Error(`Invalid aggregate field: ${field}`);
        }
        // Convert field string to SQL reference
        let fieldRef;
        const parts = field.split('.');
        if (parts.length > 1) {
            // Handle relation fields (e.g., "user.type" or "author.posts.price")
            // Check if this is a relation that has been joined
            // First check for exact field match (e.g., "user.id" in map)
            if (ctx.pathToAliasMap && ctx.pathToAliasMap.has(field)) {
                const joinAlias = ctx.pathToAliasMap.get(field);
                // The alias is the table alias, append the column name
                const columnName = parts[parts.length - 1];
                fieldRef = sql.raw(`"${joinAlias}"."${columnName}"`);
            }
            else {
                // Check if just the relation name is in the map (e.g., "user" in map)
                const relationPath = parts[0];
                if (ctx.pathToAliasMap && ctx.pathToAliasMap.has(relationPath)) {
                    const joinAlias = ctx.pathToAliasMap.get(relationPath);
                    // Replace relation name with JOIN alias
                    const remainingPath = parts.slice(1);
                    const quotedPath = `"${joinAlias}"` + (remainingPath.length > 0 ? '.' + remainingPath.map(p => `"${p}"`).join('.') : '');
                    fieldRef = sql.raw(quotedPath);
                }
                else {
                    // No JOIN alias, use field path as-is
                    const quotedPath = parts.map(p => `"${p}"`).join('.');
                    fieldRef = sql.raw(quotedPath);
                }
            }
        }
        else {
            // Simple field - add table qualification and quoting
            if (field === '*') {
                // For COUNT(*), don't qualify with table name
                fieldRef = sql.raw('*');
            }
            else if (ctx.tableName) {
                fieldRef = sql.raw(`"${ctx.tableName}"."${field}"`);
            }
            else {
                fieldRef = sql.raw(`"${field}"`);
            }
        }
        const expr = createAggregateFunction(func, fieldRef, ctx);
        attributes.push([expr, alias]);
    }
    // Add GROUP BY fields
    if (groupBy && groupBy.length > 0) {
        for (const field of groupBy) {
            // Check for date extractions like "date:year:fieldname"
            const dateExtraction = processDateExtraction(field);
            if (dateExtraction) {
                attributes.push([dateExtraction.expression, dateExtraction.alias]);
            }
            else {
                const parts = field.split('.');
                if (parts.length > 1) {
                    // Handle relation fields - use pathToAliasMap for proper JOIN alias
                    let fieldRef;
                    // Check if this is a relation that has been joined
                    // First check for exact field match (e.g., "user.id" in map)
                    if (ctx.pathToAliasMap && ctx.pathToAliasMap.has(field)) {
                        const joinAlias = ctx.pathToAliasMap.get(field);
                        // The alias is the table alias, append the column name
                        const columnName = parts[parts.length - 1];
                        fieldRef = sql.raw(`"${joinAlias}"."${columnName}"`);
                    }
                    else {
                        // Check if just the relation name is in the map (e.g., "user" in map)
                        const relationPath = parts[0];
                        if (ctx.pathToAliasMap && ctx.pathToAliasMap.has(relationPath)) {
                            const joinAlias = ctx.pathToAliasMap.get(relationPath);
                            // Replace relation name with JOIN alias
                            const remainingPath = parts.slice(1);
                            const quotedPath = `"${joinAlias}"` + (remainingPath.length > 0 ? '.' + remainingPath.map(p => `"${p}"`).join('.') : '');
                            fieldRef = sql.raw(quotedPath);
                        }
                        else {
                            // No JOIN alias, use field path as-is
                            fieldRef = sql.raw(`"${parts.join('"."')}"`);
                        }
                    }
                    const alias = field.replace(/\./g, '_');
                    attributes.push([fieldRef, alias]);
                }
                else {
                    // Simple field - qualify with table name to avoid ambiguity with joined tables
                    const fieldRef = ctx.tableName ? sql.raw(`"${ctx.tableName}"."${field}"`) : sql.raw(`"${field}"`);
                    attributes.push([fieldRef, field]);
                }
            }
        }
    }
    return attributes;
}
/**
 * Build GROUP BY expressions using
 *
 * @param groupBy - Array of fields to group by
 * @param columns - Optional map of column objects to use instead of strings
 * @param pathToAliasMap - Optional map of relation paths to JOIN aliases
 * @returns Array of SQL expressions for GROUP BY clause
 */
export function buildGroupByExpressions(groupBy, columns, pathToAliasMap, tableName) {
    return groupBy.map(field => {
        // Check for date extractions (validates its own date:part:column syntax)
        const dateExtraction = processDateExtraction(field, columns?.[field]);
        if (dateExtraction) {
            return dateExtraction.expression;
        }
        // Reject any group-by field whose identifier(s) aren't safe before raw interpolation.
        if (!isSafeFieldPath(field)) {
            throw new Error(`Invalid groupBy field: ${field}`);
        }
        // If we have a column object, use it directly 
        if (columns?.[field]) {
            return columns[field];
        }
        // Handle relation fields - use sql template for identifiers
        const parts = field.split('.');
        if (parts.length > 1) {
            // Check if this is a relation that has been joined
            // First check for exact field match (e.g., "user.id" in map)
            if (pathToAliasMap && pathToAliasMap.has(field)) {
                const joinAlias = pathToAliasMap.get(field);
                // The alias is the table alias, append the column name
                const columnName = parts[parts.length - 1];
                return sql.raw(`"${joinAlias}"."${columnName}"`);
            }
            // Check if just the relation name is in the map (e.g., "user" in map)
            const relationPath = parts[0];
            if (pathToAliasMap && pathToAliasMap.has(relationPath)) {
                const joinAlias = pathToAliasMap.get(relationPath);
                // Replace relation name with JOIN alias
                const remainingPath = parts.slice(1);
                const quotedPath = `"${joinAlias}"` + (remainingPath.length > 0 ? '.' + remainingPath.map(p => `"${p}"`).join('.') : '');
                return sql.raw(quotedPath);
            }
            // No JOIN alias, use field path as-is
            const quotedPath = parts.map(p => `"${p}"`).join('.');
            return sql.raw(quotedPath);
        }
        // Simple field - qualify with table name to avoid ambiguity with joined tables
        if (tableName) {
            return sql.raw(`"${tableName}"."${field}"`);
        }
        return sql.raw(`"${field}"`);
    });
}
/**
 * Count records with optional distinct
 *
 * @param column - Column to count (or '*' for all)
 * @param distinct - Whether to count distinct values only
 */
export function countRecords(column, distinct = false) {
    if (!column) {
        return sql `COUNT(*)`;
    }
    if (distinct) {
        return sql `COUNT(DISTINCT ${column})`;
    }
    return sql `COUNT(${column})`;
}
/**
 * Sum column values
 */
export function sumColumn(column) {
    return sql `SUM(${column})`;
}
/**
 * Average column values
 */
export function avgColumn(column) {
    return sql `AVG(${column})`;
}
/**
 * Minimum column value
 */
export function minColumn(column) {
    return sql `MIN(${column})`;
}
/**
 * Maximum column value
 */
export function maxColumn(column) {
    return sql `MAX(${column})`;
}
/**
 * Array aggregation (collect values into array)
 */
export function arrayAgg(column, distinct = false) {
    if (distinct) {
        return sql `ARRAY_AGG(DISTINCT ${column})`;
    }
    return sql `ARRAY_AGG(${column})`;
}
/**
 * String aggregation (concatenate values)
 */
export function stringAgg(column, delimiter = ', ') {
    return sql `STRING_AGG(${column}, ${delimiter})`;
}
/**
 * JSON aggregation (collect rows as JSON array)
 */
export function jsonAgg(rowExpression) {
    return sql `JSON_AGG(${rowExpression})`;
}
/**
 * Date part extraction using
 *
 * @param part - Date part to extract (year, month, day, hour, minute, week, dow, isodow)
 * @param column - Date/timestamp column
 * @example
 * const yearCol = extractDatePart('year', users.createdAt);
 * // Equivalent to: EXTRACT(YEAR FROM users.createdAt)
 */
export function extractDatePart(part, column) {
    const partUpper = part.toUpperCase();
    // Use sql<number> for type safety and .mapWith(Number) for runtime conversion
    return sql `extract(${sql.raw(partUpper)} from ${column})`.mapWith(Number);
}
/**
 * Date truncation
 *
 * @param precision - Time precision (day, week, month, year, hour, minute)
 * @param column - Date/timestamp column
 * @example
 * const dailyGroups = truncateDate('day', orders.createdAt);
 * // Equivalent to: DATE_TRUNC('day', orders.createdAt)
 */
export function truncateDate(precision, column) {
    // Use sql<Date> for type safety - returns timestamp
    return sql `date_trunc(${precision}, ${column})`;
}
/**
 * Conditional count (COUNT with CASE WHEN)
 *
 * @example
 * ```typescript
 * // Count active users
 * const activeCount = conditionalCount(
 *   eq(usersTable.status, 'active')
 * );
 * ```
 */
export function conditionalCount(condition) {
    return sql `COUNT(CASE WHEN ${condition} THEN 1 END)`;
}
/**
 * Conditional sum (SUM with CASE WHEN)
 */
export function conditionalSum(column, condition) {
    return sql `SUM(CASE WHEN ${condition} THEN ${column} ELSE 0 END)`;
}
/**
 * Percentile calculation
 *
 * @param column - Numeric column
 * @param percentile - Percentile value (0.0 to 1.0)
 * @param discrete - Use discrete (percentile_disc) vs continuous (percentile_cont)
 * @example
 * const median = calculatePercentile(sales.amount, 0.5);
 * // Equivalent to: PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sales.amount)
 */
export function calculatePercentile(column, percentile, discrete = false) {
    const func = discrete ? 'percentile_disc' : 'percentile_cont';
    return sql `${sql.raw(func)}(${percentile}) within group (order by ${column})`.mapWith(Number);
}
/**
 * Standard deviation
 */
export function stddev(column, population = false) {
    const func = population ? 'stddev_pop' : 'stddev_samp';
    return sql `${sql.raw(func)}(${column})`.mapWith(Number);
}
/**
 * Variance
 */
export function variance(column, population = false) {
    const func = population ? 'var_pop' : 'var_samp';
    return sql `${sql.raw(func)}(${column})`.mapWith(Number);
}
/**
 * Coalesce (return first non-null value)
 */
export function coalesce(...values) {
    const valueParts = values.map(v => v && typeof v === 'object' && 'queryChunks' in v ? v : sql.raw(String(v)));
    return sql `COALESCE(${sql.join(valueParts, sql `, `)})`;
}
/**
 * Full-text search matching
 * @param columns - Array of column objects to search
 * @param searchQuery - Search query string
 * @returns SQL condition for full-text matching
 */
export function applyFullTextSearch(columns, searchQuery) {
    // Prepare the full-text search query
    const tsQuery = searchQuery.trim().replace(/\s+/g, ":* & ") + ":*";
    const escapedTsQuery = tsQuery.replace(/'/g, "''");
    // Build concatenated fields using sql template
    const concatParts = columns.map(col => sql `coalesce(${col}::text, '')`);
    const concatFields = sql.join(concatParts, sql ` || ' ' || `);
    // Use sql<boolean> for the comparison result
    return sql `to_tsvector('english', ${concatFields}) @@ to_tsquery('english', ${escapedTsQuery})`;
}
/**
 * Geographic distance calculation
 *
 * @param column - Geography/geometry column
 * @param targetPoint - GeoJSON point
 * @returns SQL for distance in meters
 */
export function calculateDistance(column, targetPoint) {
    const geoJSON = JSON.stringify(targetPoint);
    // Use ST_DistanceSpheroid for accurate earth-surface distance in meters
    // This is more reliable than ::geography casting across different PostGIS versions
    return sql `ST_DistanceSpheroid(
    ${column},
    ST_SetSRID(ST_GeomFromGeoJSON(${geoJSON}), 4326),
    'SPHEROID["WGS 84",6378137,298.257223563]'
  )`.mapWith(Number);
}
/**
 * Build HAVING clause from filter
 * Similar to WHERE but for aggregate results
 *
 * Note: HAVING clause is not currently used in the application.
 * This is a placeholder for future implementation if aggregate filtering is needed.
 */
export function buildHavingClause(filter) {
    // HAVING clause would use similar logic to drizzleWhere but for aggregate columns
    // Currently not implemented as no use cases require it
    if (!filter || Object.keys(filter).length === 0) {
        return undefined;
    }
    // Not implemented - would require building conditions for aggregate expressions
    // Example: HAVING COUNT(*) > 5, HAVING SUM(amount) >= 100
    return undefined;
}
//# sourceMappingURL=aggregationUtils.js.map