package com.cameleer.server.app.storage;

import com.cameleer.server.core.storage.ServerMetricsQueryStore;
import com.cameleer.server.core.storage.model.ServerInstanceInfo;
import com.cameleer.server.core.storage.model.ServerMetricCatalogEntry;
import com.cameleer.server.core.storage.model.ServerMetricPoint;
import com.cameleer.server.core.storage.model.ServerMetricQueryRequest;
import com.cameleer.server.core.storage.model.ServerMetricQueryResponse;
import com.cameleer.server.core.storage.model.ServerMetricSeries;
import org.springframework.jdbc.core.JdbcTemplate;

import java.sql.Array;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Pattern;

/**
 * ClickHouse-backed {@link ServerMetricsQueryStore}.
 *
 * <p>Safety rules for every query:
 * <ul>
 *   <li>tenant_id always bound as a parameter — no cross-tenant reads.</li>
 *   <li>Identifier-like inputs (metric name, statistic, tag keys,
 *       aggregation, mode) are regex-validated. Tag keys flow through the
 *       query as JDBC parameter-bound values of {@code tags[?]} map lookups,
 *       so even with a "safe" regex they cannot inject SQL.</li>
 *   <li>Literal values ({@code from}, {@code to}, tag filter values,
 *       server_instance_id allow-list) always go through {@code ?}.</li>
 *   <li>The time range is capped at {@link #MAX_RANGE}.</li>
 *   <li>Result cardinality is capped at {@link #MAX_SERIES} series.</li>
 * </ul>
 */
public class ClickHouseServerMetricsQueryStore implements ServerMetricsQueryStore {

    private static final Pattern SAFE_IDENTIFIER = Pattern.compile("^[a-zA-Z0-9._]+$");
    private static final Pattern SAFE_STATISTIC  = Pattern.compile("^[a-z_]+$");

    private static final Set<String> AGGREGATIONS = Set.of("avg", "sum", "max", "min", "latest");
    private static final Set<String> MODES = Set.of("raw", "delta");

    /** Maximum {@code to - from} window accepted by the API. */
    static final Duration MAX_RANGE = Duration.ofDays(31);

    /** Clamp bounds and default for {@code stepSeconds}. */
    static final int MIN_STEP = 10;
    static final int MAX_STEP = 3600;
    static final int DEFAULT_STEP = 60;

    /** Defence against group-by explosion — limit the series count per response. */
    static final int MAX_SERIES = 500;

    private final String tenantId;
    private final JdbcTemplate jdbc;

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

    // ── catalog ─────────────────────────────────────────────────────────

    @Override
    public List<ServerMetricCatalogEntry> catalog(Instant from, Instant to) {
        requireRange(from, to);
        String sql = """
                SELECT
                    metric_name,
                    any(metric_type) AS metric_type,
                    arraySort(groupUniqArray(statistic)) AS statistics,
                    arraySort(arrayDistinct(arrayFlatten(groupArray(mapKeys(tags))))) AS tag_keys
                FROM server_metrics
                WHERE tenant_id = ?
                  AND collected_at >= ?
                  AND collected_at < ?
                GROUP BY metric_name
                ORDER BY metric_name
                """;
        return jdbc.query(sql, (rs, n) -> new ServerMetricCatalogEntry(
                rs.getString("metric_name"),
                rs.getString("metric_type"),
                arrayToStringList(rs.getArray("statistics")),
                arrayToStringList(rs.getArray("tag_keys"))
        ), tenantId, Timestamp.from(from), Timestamp.from(to));
    }

    // ── instances ───────────────────────────────────────────────────────

    @Override
    public List<ServerInstanceInfo> listInstances(Instant from, Instant to) {
        requireRange(from, to);
        String sql = """
                SELECT
                    server_instance_id,
                    min(collected_at) AS first_seen,
                    max(collected_at) AS last_seen
                FROM server_metrics
                WHERE tenant_id = ?
                  AND collected_at >= ?
                  AND collected_at < ?
                GROUP BY server_instance_id
                ORDER BY last_seen DESC
                """;
        return jdbc.query(sql, (rs, n) -> new ServerInstanceInfo(
                rs.getString("server_instance_id"),
                rs.getTimestamp("first_seen").toInstant(),
                rs.getTimestamp("last_seen").toInstant()
        ), tenantId, Timestamp.from(from), Timestamp.from(to));
    }

    // ── query ───────────────────────────────────────────────────────────

    @Override
    public ServerMetricQueryResponse query(ServerMetricQueryRequest request) {
        if (request == null) throw new IllegalArgumentException("request is required");
        String metric = requireSafeIdentifier(request.metric(), "metric");
        requireRange(request.from(), request.to());

        String aggregation = request.aggregation() != null ? request.aggregation().toLowerCase() : "avg";
        if (!AGGREGATIONS.contains(aggregation)) {
            throw new IllegalArgumentException("aggregation must be one of " + AGGREGATIONS);
        }

        String mode = request.mode() != null ? request.mode().toLowerCase() : "raw";
        if (!MODES.contains(mode)) {
            throw new IllegalArgumentException("mode must be one of " + MODES);
        }

        int step = request.stepSeconds() != null ? request.stepSeconds() : DEFAULT_STEP;
        if (step < MIN_STEP || step > MAX_STEP) {
            throw new IllegalArgumentException(
                    "stepSeconds must be in [" + MIN_STEP + "," + MAX_STEP + "]");
        }

        String statistic = request.statistic();
        if (statistic != null && !SAFE_STATISTIC.matcher(statistic).matches()) {
            throw new IllegalArgumentException("statistic contains unsafe characters");
        }

        List<String> groupByTags = request.groupByTags() != null
                ? request.groupByTags() : List.of();
        for (String t : groupByTags) requireSafeIdentifier(t, "groupByTag");

        Map<String, String> filterTags = request.filterTags() != null
                ? request.filterTags() : Map.of();
        for (String t : filterTags.keySet()) requireSafeIdentifier(t, "filterTag key");

        List<String> instanceAllowList = request.serverInstanceIds() != null
                ? request.serverInstanceIds() : List.of();

        boolean isDelta = "delta".equals(mode);
        boolean isMean  = "mean".equals(statistic);

        String sql = isDelta
                ? buildDeltaSql(step, groupByTags, filterTags, instanceAllowList, statistic, isMean)
                : buildRawSql(step, groupByTags, filterTags, instanceAllowList,
                              statistic, aggregation, isMean);

        List<Object> params = buildParams(groupByTags, metric, statistic, isMean,
                                          request.from(), request.to(),
                                          filterTags, instanceAllowList);

        List<Row> rows = jdbc.query(sql, (rs, n) -> {
            int idx = 1;
            Instant bucket = rs.getTimestamp(idx++).toInstant();
            List<String> tagValues = new ArrayList<>(groupByTags.size());
            for (int g = 0; g < groupByTags.size(); g++) {
                tagValues.add(rs.getString(idx++));
            }
            double value = rs.getDouble(idx);
            return new Row(bucket, tagValues, value);
        }, params.toArray());

        return assembleSeries(rows, metric, statistic, aggregation, mode, step, groupByTags);
    }

    // ── SQL builders ────────────────────────────────────────────────────

    /**
     * Builds a single-pass SQL for raw mode:
     * <pre>{@code
     * SELECT bucket, tag0, ..., <agg>(metric_value) AS value
     * FROM server_metrics WHERE ...
     * GROUP BY bucket, tag0, ...
     * ORDER BY bucket, tag0, ...
     * }</pre>
     * For {@code statistic=mean}, replaces the aggregate with
     * {@code sumIf(value, statistic IN ('total','total_time')) / nullIf(sumIf(value, statistic='count'), 0)}.
     */
    private String buildRawSql(int step, List<String> groupByTags,
                               Map<String, String> filterTags,
                               List<String> instanceAllowList,
                               String statistic, String aggregation, boolean isMean) {
        StringBuilder s = new StringBuilder(512);
        s.append("SELECT\n  toDateTime64(toStartOfInterval(collected_at, INTERVAL ")
         .append(step).append(" SECOND), 3) AS bucket");
        for (int i = 0; i < groupByTags.size(); i++) {
            s.append(",\n  tags[?] AS tag").append(i);
        }
        s.append(",\n  ").append(isMean ? meanExpr() : scalarAggExpr(aggregation))
         .append(" AS value\nFROM server_metrics\n");
        appendWhereClause(s, filterTags, instanceAllowList, statistic, isMean);
        s.append("GROUP BY bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append("\nORDER BY bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        return s.toString();
    }

    /**
     * Builds a three-level SQL for delta mode. Inner fills one
     * (bucket, instance, tag-group) row via {@code max(metric_value)};
     * middle computes positive-clipped per-instance differences via a
     * window function; outer sums across instances.
     */
    private String buildDeltaSql(int step, List<String> groupByTags,
                                 Map<String, String> filterTags,
                                 List<String> instanceAllowList,
                                 String statistic, boolean isMean) {
        StringBuilder s = new StringBuilder(1024);
        s.append("SELECT bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append(", sum(delta) AS value FROM (\n");

        // Middle: per-instance positive-clipped delta using window.
        s.append("  SELECT bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append(", server_instance_id, greatest(0, value - coalesce(any(value) OVER (")
         .append("PARTITION BY server_instance_id");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append(" ORDER BY bucket ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING), value)) AS delta FROM (\n");

        // Inner: one representative value per (bucket, instance, tag-group).
        s.append("    SELECT\n      toDateTime64(toStartOfInterval(collected_at, INTERVAL ")
         .append(step).append(" SECOND), 3) AS bucket,\n      server_instance_id");
        for (int i = 0; i < groupByTags.size(); i++) {
            s.append(",\n      tags[?] AS tag").append(i);
        }
        s.append(",\n      ").append(isMean ? meanExpr() : "max(metric_value)")
         .append(" AS value\n    FROM server_metrics\n");
        appendWhereClause(s, filterTags, instanceAllowList, statistic, isMean);
        s.append("    GROUP BY bucket, server_instance_id");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append("\n  ) AS bucketed\n) AS deltas\n");

        s.append("GROUP BY bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        s.append("\nORDER BY bucket");
        for (int i = 0; i < groupByTags.size(); i++) s.append(", tag").append(i);
        return s.toString();
    }

    /**
     * WHERE clause shared by both raw and delta SQL shapes. Appended at the
     * correct indent under either the single {@code FROM server_metrics}
     * (raw) or the innermost one (delta).
     */
    private void appendWhereClause(StringBuilder s, Map<String, String> filterTags,
                                   List<String> instanceAllowList,
                                   String statistic, boolean isMean) {
        s.append("    WHERE tenant_id = ?\n")
         .append("      AND metric_name = ?\n");
        if (isMean) {
            s.append("      AND statistic IN ('count', 'total', 'total_time')\n");
        } else if (statistic != null) {
            s.append("      AND statistic = ?\n");
        }
        s.append("      AND collected_at >= ?\n")
         .append("      AND collected_at < ?\n");
        for (int i = 0; i < filterTags.size(); i++) {
            s.append("      AND tags[?] = ?\n");
        }
        if (!instanceAllowList.isEmpty()) {
            s.append("      AND server_instance_id IN (")
             .append("?,".repeat(instanceAllowList.size() - 1)).append("?)\n");
        }
    }

    /**
     * SQL-positional params for both raw and delta queries (same relative
     * order because the WHERE clause is emitted by {@link #appendWhereClause}
     * only once, with the {@code tags[?]} select-list placeholders appearing
     * earlier in the SQL text).
     */
    private List<Object> buildParams(List<String> groupByTags, String metric,
                                     String statistic, boolean isMean,
                                     Instant from, Instant to,
                                     Map<String, String> filterTags,
                                     List<String> instanceAllowList) {
        List<Object> params = new ArrayList<>();
        // SELECT-list tags[?] placeholders
        params.addAll(groupByTags);
        // WHERE
        params.add(tenantId);
        params.add(metric);
        if (!isMean && statistic != null) params.add(statistic);
        params.add(Timestamp.from(from));
        params.add(Timestamp.from(to));
        for (Map.Entry<String, String> e : filterTags.entrySet()) {
            params.add(e.getKey());
            params.add(e.getValue());
        }
        params.addAll(instanceAllowList);
        return params;
    }

    private static String scalarAggExpr(String aggregation) {
        return switch (aggregation) {
            case "avg"    -> "avg(metric_value)";
            case "sum"    -> "sum(metric_value)";
            case "max"    -> "max(metric_value)";
            case "min"    -> "min(metric_value)";
            case "latest" -> "argMax(metric_value, collected_at)";
            default       -> throw new IllegalStateException("unreachable: " + aggregation);
        };
    }

    private static String meanExpr() {
        return "sumIf(metric_value, statistic IN ('total', 'total_time'))"
             + " / nullIf(sumIf(metric_value, statistic = 'count'), 0)";
    }

    // ── response assembly ───────────────────────────────────────────────

    private ServerMetricQueryResponse assembleSeries(
            List<Row> rows, String metric, String statistic,
            String aggregation, String mode, int step, List<String> groupByTags) {

        Map<List<String>, List<ServerMetricPoint>> bySignature = new LinkedHashMap<>();
        for (Row r : rows) {
            if (Double.isNaN(r.value) || Double.isInfinite(r.value)) continue;
            bySignature.computeIfAbsent(r.tagValues, k -> new ArrayList<>())
                       .add(new ServerMetricPoint(r.bucket, r.value));
        }

        if (bySignature.size() > MAX_SERIES) {
            throw new IllegalArgumentException(
                    "query produced " + bySignature.size()
                            + " series; reduce groupByTags or tighten filterTags (max "
                            + MAX_SERIES + ")");
        }

        List<ServerMetricSeries> series = new ArrayList<>(bySignature.size());
        for (Map.Entry<List<String>, List<ServerMetricPoint>> e : bySignature.entrySet()) {
            Map<String, String> tags = new LinkedHashMap<>();
            for (int i = 0; i < groupByTags.size(); i++) {
                tags.put(groupByTags.get(i), e.getKey().get(i));
            }
            series.add(new ServerMetricSeries(Collections.unmodifiableMap(tags), e.getValue()));
        }

        return new ServerMetricQueryResponse(metric,
                statistic != null ? statistic : "value",
                aggregation, mode, step, series);
    }

    // ── helpers ─────────────────────────────────────────────────────────

    private static void requireRange(Instant from, Instant to) {
        if (from == null || to == null) {
            throw new IllegalArgumentException("from and to are required");
        }
        if (!from.isBefore(to)) {
            throw new IllegalArgumentException("from must be strictly before to");
        }
        if (Duration.between(from, to).compareTo(MAX_RANGE) > 0) {
            throw new IllegalArgumentException(
                    "time range exceeds maximum of " + MAX_RANGE.toDays() + " days");
        }
    }

    private static String requireSafeIdentifier(String value, String field) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException(field + " is required");
        }
        if (!SAFE_IDENTIFIER.matcher(value).matches()) {
            throw new IllegalArgumentException(
                    field + " contains unsafe characters (allowed: [a-zA-Z0-9._])");
        }
        return value;
    }

    private static List<String> arrayToStringList(Array array) {
        if (array == null) return List.of();
        try {
            Object[] values = (Object[]) array.getArray();
            Set<String> sorted = new TreeSet<>();
            for (Object v : values) {
                if (v != null) sorted.add(v.toString());
            }
            return List.copyOf(sorted);
        } catch (Exception e) {
            return List.of();
        } finally {
            try { array.free(); } catch (Exception ignore) { }
        }
    }

    private record Row(Instant bucket, List<String> tagValues, double value) {
    }
}
