package com.cameleer.server.app.storage;

import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.StatsTimeseries;
import com.cameleer.server.core.search.StatsTimeseries.TimeseriesBucket;
import com.cameleer.server.core.search.TopError;
import com.cameleer.server.core.storage.StatsStore;
import org.springframework.jdbc.core.JdbcTemplate;

import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * ClickHouse implementation of {@link StatsStore}.
 * Reads from AggregatingMergeTree tables populated by materialized views,
 * using {@code -Merge} aggregate combinators to finalize partial states.
 *
 * <p>Queries against AggregatingMergeTree tables use literal SQL values instead
 * of JDBC prepared-statement parameters because the ClickHouse JDBC v2 driver
 * (0.9.x) wraps prepared statements in a sub-query that strips the
 * {@code AggregateFunction} column type, breaking {@code -Merge} combinators.
 * Queries against raw tables ({@code executions FINAL},
 * {@code processor_executions}) use normal prepared-statement parameters
 * since they have no AggregateFunction columns.</p>
 */
public class ClickHouseStatsStore implements StatsStore {

    private final String tenantId;
    private final JdbcTemplate jdbc;

    public ClickHouseStatsStore(String tenantId, JdbcTemplate jdbc) {
        this.tenantId = tenantId;
        this.jdbc = jdbc;
    }

    // ── Stats (aggregate) ────────────────────────────────────────────────

    @Override
    public ExecutionStats stats(Instant from, Instant to, String environment) {
        return queryStats("stats_1m_all", from, to, List.of(), true, environment);
    }

    @Override
    public ExecutionStats statsForApp(Instant from, Instant to, String applicationId, String environment) {
        return queryStats("stats_1m_app", from, to, List.of(
                new Filter("application_id", applicationId)), true, environment);
    }

    @Override
    public ExecutionStats statsForRoute(Instant from, Instant to, String routeId, String applicationId, String environment) {
        List<Filter> filters = new ArrayList<>();
        filters.add(new Filter("route_id", routeId));
        if (applicationId != null && !applicationId.isBlank()) {
            filters.add(new Filter("application_id", applicationId));
        }
        return queryStats("stats_1m_route", from, to, filters, true, environment);
    }

    @Override
    public ExecutionStats statsForProcessor(Instant from, Instant to, String routeId, String processorType) {
        return queryProcessorStatsRaw(from, to, routeId, processorType);
    }

    // ── Timeseries ───────────────────────────────────────────────────────

    @Override
    public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, String environment) {
        return queryTimeseries("stats_1m_all", from, to, bucketCount, List.of(), true, environment);
    }

    @Override
    public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationId, String environment) {
        return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
                new Filter("application_id", applicationId)), true, environment);
    }

    @Override
    public StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
                                               String routeId, String applicationId, String environment) {
        List<Filter> filters = new ArrayList<>();
        filters.add(new Filter("route_id", routeId));
        if (applicationId != null && !applicationId.isBlank()) {
            filters.add(new Filter("application_id", applicationId));
        }
        return queryTimeseries("stats_1m_route", from, to, bucketCount, filters, true, environment);
    }

    @Override
    public StatsTimeseries timeseriesForProcessor(Instant from, Instant to, int bucketCount,
                                                    String routeId, String processorType) {
        return queryProcessorTimeseriesRaw(from, to, bucketCount, routeId, processorType);
    }

    // ── Grouped timeseries ───────────────────────────────────────────────

    @Override
    public Map<String, StatsTimeseries> timeseriesGroupedByApp(Instant from, Instant to, int bucketCount, String environment) {
        return queryGroupedTimeseries("stats_1m_app", "application_id", from, to,
                bucketCount, List.of(), environment);
    }

    @Override
    public Map<String, StatsTimeseries> timeseriesGroupedByRoute(Instant from, Instant to,
                                                                   int bucketCount, String applicationId, String environment) {
        return queryGroupedTimeseries("stats_1m_route", "route_id", from, to,
                bucketCount, List.of(new Filter("application_id", applicationId)), environment);
    }

    // ── SLA compliance (raw table — prepared statements OK) ──────────────

    @Override
    public double slaCompliance(Instant from, Instant to, int thresholdMs,
                                String applicationId, String routeId, String environment) {
        String sql = "SELECT " +
                "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
                "countIf(status != 'RUNNING') AS total " +
                "FROM executions FINAL " +
                "WHERE tenant_id = ? AND start_time >= ? AND start_time < ?";

        List<Object> params = new ArrayList<>();
        params.add(thresholdMs);
        params.add(tenantId);
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = ?";
            params.add(environment);
        }
        if (applicationId != null) {
            sql += " AND application_id = ?";
            params.add(applicationId);
        }
        if (routeId != null) {
            sql += " AND route_id = ?";
            params.add(routeId);
        }

        return jdbc.query(sql, (rs, rowNum) -> {
            long total = rs.getLong("total");
            if (total == 0) return 1.0;
            return rs.getLong("compliant") * 100.0 / total;
        }, params.toArray()).stream().findFirst().orElse(1.0);
    }

    @Override
    public Map<String, long[]> slaCountsByApp(Instant from, Instant to, int defaultThresholdMs, String environment) {
        String sql = "SELECT application_id, " +
                "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
                "countIf(status != 'RUNNING') AS total " +
                "FROM executions FINAL " +
                "WHERE tenant_id = ? AND start_time >= ? AND start_time < ?";

        List<Object> params = new ArrayList<>();
        params.add(defaultThresholdMs);
        params.add(tenantId);
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = ?";
            params.add(environment);
        }
        sql += " GROUP BY application_id";

        Map<String, long[]> result = new LinkedHashMap<>();
        jdbc.query(sql, (rs) -> {
            result.put(rs.getString("application_id"),
                    new long[]{rs.getLong("compliant"), rs.getLong("total")});
        }, params.toArray());
        return result;
    }

    @Override
    public Map<String, long[]> slaCountsByRoute(Instant from, Instant to,
                                                  String applicationId, int thresholdMs, String environment) {
        String sql = "SELECT route_id, " +
                "countIf(duration_ms <= ? AND status != 'RUNNING') AS compliant, " +
                "countIf(status != 'RUNNING') AS total " +
                "FROM executions FINAL " +
                "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
                "AND application_id = ?";

        List<Object> params = new ArrayList<>();
        params.add(thresholdMs);
        params.add(tenantId);
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        params.add(applicationId);
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = ?";
            params.add(environment);
        }
        sql += " GROUP BY route_id";

        Map<String, long[]> result = new LinkedHashMap<>();
        jdbc.query(sql, (rs) -> {
            result.put(rs.getString("route_id"),
                    new long[]{rs.getLong("compliant"), rs.getLong("total")});
        }, params.toArray());
        return result;
    }

    // ── Top errors (raw table — prepared statements OK) ──────────────────

    @Override
    public List<TopError> topErrors(Instant from, Instant to, String applicationId,
                                     String routeId, int limit, String environment) {
        StringBuilder where = new StringBuilder(
                "status = 'FAILED' AND start_time >= ? AND start_time < ?");
        List<Object> params = new ArrayList<>();
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        if (environment != null && !environment.isBlank()) {
            where.append(" AND environment = ?");
            params.add(environment);
        }
        if (applicationId != null) {
            where.append(" AND application_id = ?");
            params.add(applicationId);
        }

        String table;
        String groupId;
        if (routeId != null) {
            table = "processor_executions";
            groupId = "processor_id";
            where.append(" AND route_id = ?");
            params.add(routeId);
        } else {
            table = "executions FINAL";
            groupId = "route_id";
        }

        Instant fiveMinAgo = Instant.now().minus(5, ChronoUnit.MINUTES);
        Instant tenMinAgo = Instant.now().minus(10, ChronoUnit.MINUTES);

        String sql = "WITH counted AS (" +
                "  SELECT COALESCE(error_type, substring(error_message, 1, 200)) AS error_key, " +
                "  " + groupId + " AS group_id, " +
                "  count() AS cnt, max(start_time) AS last_seen " +
                "  FROM " + table + " WHERE tenant_id = ? AND " + where +
                "  GROUP BY error_key, group_id ORDER BY cnt DESC LIMIT ?" +
                "), velocity AS (" +
                "  SELECT COALESCE(error_type, substring(error_message, 1, 200)) AS error_key, " +
                "  countIf(start_time >= ?) AS recent_5m, " +
                "  countIf(start_time >= ? AND start_time < ?) AS prev_5m " +
                "  FROM " + table + " WHERE tenant_id = ? AND " + where +
                "  GROUP BY error_key" +
                ") SELECT c.error_key, c.group_id, c.cnt, c.last_seen, " +
                "  COALESCE(v.recent_5m, 0) / 5.0 AS velocity, " +
                "  CASE " +
                "    WHEN COALESCE(v.recent_5m, 0) > COALESCE(v.prev_5m, 0) * 1.2 THEN 'accelerating' " +
                "    WHEN COALESCE(v.recent_5m, 0) < COALESCE(v.prev_5m, 0) * 0.8 THEN 'decelerating' " +
                "    ELSE 'stable' END AS trend " +
                "FROM counted c LEFT JOIN velocity v ON c.error_key = v.error_key " +
                "ORDER BY c.cnt DESC";

        List<Object> fullParams = new ArrayList<>();
        fullParams.add(tenantId);
        fullParams.addAll(params);
        fullParams.add(limit);
        fullParams.add(Timestamp.from(fiveMinAgo));
        fullParams.add(Timestamp.from(tenMinAgo));
        fullParams.add(Timestamp.from(fiveMinAgo));
        fullParams.add(tenantId);
        fullParams.addAll(params);

        return jdbc.query(sql, (rs, rowNum) -> {
            String errorKey = rs.getString("error_key");
            String gid = rs.getString("group_id");
            return new TopError(
                    errorKey,
                    routeId != null ? routeId : gid,
                    routeId != null ? gid : null,
                    rs.getLong("cnt"),
                    rs.getDouble("velocity"),
                    rs.getString("trend"),
                    rs.getTimestamp("last_seen").toInstant());
        }, fullParams.toArray());
    }

    @Override
    public int activeErrorTypes(Instant from, Instant to, String applicationId, String environment) {
        String sql = "SELECT COUNT(DISTINCT COALESCE(error_type, substring(error_message, 1, 200))) " +
                "FROM executions FINAL " +
                "WHERE tenant_id = ? AND status = 'FAILED' AND start_time >= ? AND start_time < ?";

        List<Object> params = new ArrayList<>();
        params.add(tenantId);
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = ?";
            params.add(environment);
        }
        if (applicationId != null) {
            sql += " AND application_id = ?";
            params.add(applicationId);
        }

        Integer count = jdbc.queryForObject(sql, Integer.class, params.toArray());
        return count != null ? count : 0;
    }

    // ── Punchcard (AggregatingMergeTree — literal SQL) ───────────────────

    @Override
    public List<PunchcardCell> punchcard(Instant from, Instant to, String applicationId, String environment) {
        String view = applicationId != null ? "stats_1m_app" : "stats_1m_all";
        String sql = "SELECT toDayOfWeek(bucket, 1) % 7 AS weekday, " +
                "toHour(bucket) AS hour, " +
                "uniqMerge(total_count) AS total_count, " +
                "uniqIfMerge(failed_count) AS failed_count " +
                "FROM " + view +
                " WHERE tenant_id = " + lit(tenantId) +
                " AND bucket >= " + lit(from) +
                " AND bucket < " + lit(to);
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = " + lit(environment);
        }
        if (applicationId != null) {
            sql += " AND application_id = " + lit(applicationId);
        }
        sql += " GROUP BY weekday, hour ORDER BY weekday, hour";

        return jdbc.query(sql, (rs, rowNum) -> new PunchcardCell(
                rs.getInt("weekday"), rs.getInt("hour"),
                rs.getLong("total_count"), rs.getLong("failed_count")));
    }

    // ── Private helpers ──────────────────────────────────────────────────

    private record Filter(String column, String value) {}

    /**
     * Format an Instant as a ClickHouse DateTime literal explicitly typed in UTC.
     * The explicit `toDateTime(..., 'UTC')` cast avoids depending on the session
     * timezone matching the `bucket DateTime('UTC')` column type.
     */
    private static String lit(Instant instant) {
        String raw = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
                .withZone(java.time.ZoneOffset.UTC)
                .format(instant.truncatedTo(ChronoUnit.SECONDS));
        return "toDateTime('" + raw + "', 'UTC')";
    }

    /** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
    private static String lit(String value) {
        return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
    }

    /** Convert Instant to java.sql.Timestamp for JDBC binding. */
    private static Timestamp ts(Instant instant) {
        return Timestamp.from(instant);
    }

    /**
     * Build -Merge combinator SQL for the given view and time range.
     */
    private String buildStatsSql(String view, Instant rangeFrom, Instant rangeTo,
                                  List<Filter> filters, boolean hasRunning, String environment) {
        String runningCol = hasRunning ? "uniqIfMerge(running_count)" : "0";
        String sql = "SELECT " +
                "uniqMerge(total_count) AS total_count, " +
                "uniqIfMerge(failed_count) AS failed_count, " +
                "sumMerge(duration_sum) AS duration_sum, " +
                "quantileMerge(0.99)(p99_duration) AS p99_duration, " +
                runningCol + " AS active_count " +
                "FROM " + view +
                " WHERE tenant_id = " + lit(tenantId) +
                " AND bucket >= " + lit(rangeFrom) +
                " AND bucket < " + lit(rangeTo);
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = " + lit(environment);
        }
        for (Filter f : filters) {
            sql += " AND " + f.column() + " = " + lit(f.value());
        }
        return sql;
    }

    /**
     * Query an AggregatingMergeTree stats table using -Merge combinators.
     * Uses literal SQL to avoid ClickHouse JDBC driver PreparedStatement issues.
     */
    private ExecutionStats queryStats(String view, Instant from, Instant to,
                                       List<Filter> filters, boolean hasRunning, String environment) {

        String sql = buildStatsSql(view, from, to, filters, hasRunning, environment);

        long totalCount = 0, failedCount = 0, avgDuration = 0, p99Duration = 0, activeCount = 0;
        var currentResult = jdbc.query(sql, (rs, rowNum) -> {
            long tc = rs.getLong("total_count");
            long fc = rs.getLong("failed_count");
            long ds = rs.getLong("duration_sum");    // Nullable -> 0 if null
            long p99 = (long) rs.getDouble("p99_duration"); // quantileMerge returns Float64
            long ac = rs.getLong("active_count");
            return new long[]{tc, fc, ds, p99, ac};
        });
        if (!currentResult.isEmpty()) {
            long[] r = currentResult.get(0);
            totalCount = r[0]; failedCount = r[1];
            avgDuration = totalCount > 0 ? r[2] / totalCount : 0;
            p99Duration = r[3]; activeCount = r[4];
        }

        // Previous period (shifted back 24h)
        Instant prevFrom = from.minus(Duration.ofHours(24));
        Instant prevTo = to.minus(Duration.ofHours(24));
        String prevSql = buildStatsSql(view, prevFrom, prevTo, filters, hasRunning, environment);

        long prevTotal = 0, prevFailed = 0, prevAvg = 0, prevP99 = 0;
        var prevResult = jdbc.query(prevSql, (rs, rowNum) -> {
            long tc = rs.getLong("total_count");
            long fc = rs.getLong("failed_count");
            long ds = rs.getLong("duration_sum");
            long p99 = (long) rs.getDouble("p99_duration");
            return new long[]{tc, fc, ds, p99};
        });
        if (!prevResult.isEmpty()) {
            long[] r = prevResult.get(0);
            prevTotal = r[0]; prevFailed = r[1];
            prevAvg = prevTotal > 0 ? r[2] / prevTotal : 0;
            prevP99 = r[3];
        }

        // Today total
        Instant todayStart = Instant.now().truncatedTo(ChronoUnit.DAYS);
        String todaySql = buildStatsSql(view, todayStart, Instant.now(), filters, hasRunning, environment);

        long totalToday = 0;
        var todayResult = jdbc.query(todaySql, (rs, rowNum) -> rs.getLong("total_count"));
        if (!todayResult.isEmpty()) totalToday = todayResult.get(0);

        return new ExecutionStats(
                totalCount, failedCount, avgDuration, p99Duration, activeCount,
                totalToday, prevTotal, prevFailed, prevAvg, prevP99);
    }

    /**
     * Timeseries from AggregatingMergeTree using -Merge combinators.
     */
    private StatsTimeseries queryTimeseries(String view, Instant from, Instant to,
                                             int bucketCount, List<Filter> filters,
                                             boolean hasRunningCount, String environment) {
        long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
        if (intervalSeconds < 60) intervalSeconds = 60;

        String runningCol = hasRunningCount ? "uniqIfMerge(running_count)" : "0";

        String sql = "SELECT " +
                "toStartOfInterval(bucket, INTERVAL " + intervalSeconds + " SECOND) AS period, " +
                "uniqMerge(total_count) AS total_count, " +
                "uniqIfMerge(failed_count) AS failed_count, " +
                "sumMerge(duration_sum) AS duration_sum, " +
                "quantileMerge(0.99)(p99_duration) AS p99_duration, " +
                runningCol + " AS active_count " +
                "FROM " + view +
                " WHERE tenant_id = " + lit(tenantId) +
                " AND bucket >= " + lit(from) +
                " AND bucket < " + lit(to);
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = " + lit(environment);
        }
        for (Filter f : filters) {
            sql += " AND " + f.column() + " = " + lit(f.value());
        }
        sql += " GROUP BY period ORDER BY period";

        List<TimeseriesBucket> buckets = jdbc.query(sql, (rs, rowNum) -> {
            long tc = rs.getLong("total_count");
            long ds = rs.getLong("duration_sum");
            return new TimeseriesBucket(
                        rs.getTimestamp("period").toInstant(),
                        tc, rs.getLong("failed_count"),
                        tc > 0 ? ds / tc : 0, (long) rs.getDouble("p99_duration"),
                        rs.getLong("active_count"));
        });

        return new StatsTimeseries(buckets);
    }

    /**
     * Grouped timeseries from AggregatingMergeTree.
     */
    private Map<String, StatsTimeseries> queryGroupedTimeseries(
            String view, String groupCol, Instant from, Instant to,
            int bucketCount, List<Filter> filters, String environment) {

        long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
        if (intervalSeconds < 60) intervalSeconds = 60;

        String sql = "SELECT " +
                "toStartOfInterval(bucket, INTERVAL " + intervalSeconds + " SECOND) AS period, " +
                groupCol + " AS group_key, " +
                "uniqMerge(total_count) AS total_count, " +
                "uniqIfMerge(failed_count) AS failed_count, " +
                "sumMerge(duration_sum) AS duration_sum, " +
                "quantileMerge(0.99)(p99_duration) AS p99_duration, " +
                "uniqIfMerge(running_count) AS active_count " +
                "FROM " + view +
                " WHERE tenant_id = " + lit(tenantId) +
                " AND bucket >= " + lit(from) +
                " AND bucket < " + lit(to);
        if (environment != null && !environment.isBlank()) {
            sql += " AND environment = " + lit(environment);
        }
        for (Filter f : filters) {
            sql += " AND " + f.column() + " = " + lit(f.value());
        }
        sql += " GROUP BY period, group_key ORDER BY period, group_key";

        Map<String, List<TimeseriesBucket>> grouped = new LinkedHashMap<>();
        jdbc.query(sql, (rs) -> {
            String key = rs.getString("group_key");
            long tc = rs.getLong("total_count");
            long ds = rs.getLong("duration_sum");
            TimeseriesBucket bucket = new TimeseriesBucket(
                    rs.getTimestamp("period").toInstant(),
                    tc, rs.getLong("failed_count"),
                    tc > 0 ? ds / tc : 0, (long) rs.getDouble("p99_duration"),
                    rs.getLong("active_count"));
            grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(bucket);
        });

        Map<String, StatsTimeseries> result = new LinkedHashMap<>();
        grouped.forEach((key, buckets) -> result.put(key, new StatsTimeseries(buckets)));
        return result;
    }

    /**
     * Direct aggregation on processor_executions for processor-level stats.
     */
    private ExecutionStats queryProcessorStatsRaw(Instant from, Instant to,
                                                   String routeId, String processorType) {
        String sql = "SELECT " +
                "count() AS total_count, " +
                "countIf(status = 'FAILED') AS failed_count, " +
                "CASE WHEN count() > 0 THEN sum(duration_ms) / count() ELSE 0 END AS avg_duration, " +
                "quantile(0.99)(duration_ms) AS p99_duration, " +
                "0 AS active_count " +
                "FROM processor_executions " +
                "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
                "AND route_id = ? AND processor_type = ?";

        long totalCount = 0, failedCount = 0, avgDuration = 0, p99Duration = 0;
        var currentResult = jdbc.query(sql, (rs, rowNum) -> new long[]{
                rs.getLong("total_count"), rs.getLong("failed_count"),
                (long) rs.getDouble("avg_duration"), (long) rs.getDouble("p99_duration"),
                rs.getLong("active_count")
        }, tenantId, ts(from), ts(to), routeId, processorType);
        if (!currentResult.isEmpty()) {
            long[] r = currentResult.get(0);
            totalCount = r[0]; failedCount = r[1]; avgDuration = r[2]; p99Duration = r[3];
        }

        Instant prevFrom = from.minus(Duration.ofHours(24));
        Instant prevTo = to.minus(Duration.ofHours(24));
        long prevTotal = 0, prevFailed = 0, prevAvg = 0, prevP99 = 0;
        var prevResult = jdbc.query(sql, (rs, rowNum) -> new long[]{
                rs.getLong("total_count"), rs.getLong("failed_count"),
                (long) rs.getDouble("avg_duration"), (long) rs.getDouble("p99_duration")
        }, tenantId, ts(prevFrom), ts(prevTo), routeId, processorType);
        if (!prevResult.isEmpty()) {
            long[] r = prevResult.get(0);
            prevTotal = r[0]; prevFailed = r[1]; prevAvg = r[2]; prevP99 = r[3];
        }

        Instant todayStart = Instant.now().truncatedTo(ChronoUnit.DAYS);
        long totalToday = 0;
        var todayResult = jdbc.query(sql, (rs, rowNum) -> rs.getLong("total_count"),
                tenantId, ts(todayStart), ts(Instant.now()), routeId, processorType);
        if (!todayResult.isEmpty()) totalToday = todayResult.get(0);

        return new ExecutionStats(
                totalCount, failedCount, avgDuration, p99Duration, 0,
                totalToday, prevTotal, prevFailed, prevAvg, prevP99);
    }

    /**
     * Direct aggregation on processor_executions for processor-level timeseries.
     */
    private StatsTimeseries queryProcessorTimeseriesRaw(Instant from, Instant to,
                                                         int bucketCount,
                                                         String routeId, String processorType) {
        long intervalSeconds = Duration.between(from, to).toSeconds() / Math.max(bucketCount, 1);
        if (intervalSeconds < 60) intervalSeconds = 60;

        String sql = "SELECT " +
                "toStartOfInterval(start_time, INTERVAL " + intervalSeconds + " SECOND) AS period, " +
                "count() AS total_count, " +
                "countIf(status = 'FAILED') AS failed_count, " +
                "CASE WHEN count() > 0 THEN sum(duration_ms) / count() ELSE 0 END AS avg_duration, " +
                "quantile(0.99)(duration_ms) AS p99_duration, " +
                "0 AS active_count " +
                "FROM processor_executions " +
                "WHERE tenant_id = ? AND start_time >= ? AND start_time < ? " +
                "AND route_id = ? AND processor_type = ? " +
                "GROUP BY period ORDER BY period";

        List<TimeseriesBucket> buckets = jdbc.query(sql, (rs, rowNum) ->
                new TimeseriesBucket(
                        rs.getTimestamp("period").toInstant(),
                        rs.getLong("total_count"), rs.getLong("failed_count"),
                        (long) rs.getDouble("avg_duration"), (long) rs.getDouble("p99_duration"),
                        rs.getLong("active_count")
                ), tenantId, ts(from), ts(to), routeId, processorType);

        return new StatsTimeseries(buckets);
    }
}
