package com.walkguide.performance;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.util.*;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.*;

/**
 * K6ResultParserTest
 *
 * Unit tests untuk memverifikasi logika parsing output k6 load test.
 * Tidak memerlukan file fisik atau koneksi eksternal — semua data di-mock sebagai
 * in-memory JSON (NDJSON format) sesuai dengan format output k6 --out json.
 *
 * Struktur NDJSON k6:
 * {"type":"Point","metric":"http_req_duration","data":{"time":"...","value":123.45,"tags":{"endpoint":"login"}}}
 *
 * Setiap nested class merepresentasikan satu aspek dari pipeline parsing:
 *  1. NdjsonParser       — parsing NDJSON line-by-line
 *  2. MetricAggregator   — aggregasi nilai per metric name
 *  3. StatCalculator     — kalkulasi statistik (avg, p95, p99, dll)
 *  4. ThresholdEvaluator — evaluasi pass/fail per threshold
 *  5. KeyMetricExtractor — extract 5 key metrics wajib (Pillar 3)
 *  6. WalkGuideMetrics   — breakdown per endpoint spesifik WalkGuide
 *  7. EndpointBreakdown  — grouping metric berdasarkan tag endpoint
 *  8. EdgeCases          — empty data, malformed JSON, single data point
 */
@DisplayName("K6ResultParser — Load Test Output Parsing")
class K6ResultParserTest {

    private ObjectMapper mapper;

    @BeforeEach
    void setUp() {
        mapper = new ObjectMapper();
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Helper: buat satu NDJSON data point seperti output k6
    // ─────────────────────────────────────────────────────────────────────────
    private ObjectNode makePoint(String metric, double value, String endpoint) {
        ObjectNode root = mapper.createObjectNode();
        root.put("type", "Point");
        root.put("metric", metric);

        ObjectNode data = mapper.createObjectNode();
        data.put("time", "2024-01-01T00:00:00Z");
        data.put("value", value);

        ObjectNode tags = mapper.createObjectNode();
        tags.put("endpoint", endpoint);
        data.set("tags", tags);

        root.set("data", data);
        return root;
    }

    private ObjectNode makePoint(String metric, double value) {
        return makePoint(metric, value, "unknown");
    }

    private String toNdjson(List<ObjectNode> points) {
        return points.stream()
                .map(ObjectNode::toString)
                .collect(Collectors.joining("\n"));
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Inline parser logic (mirrors result-parser.js untuk ditest di Java)
    // ─────────────────────────────────────────────────────────────────────────

    /** Parse NDJSON string → list of JsonNode data points */
    private List<JsonNode> parseNdjson(String ndjson) {
        List<JsonNode> points = new ArrayList<>();
        for (String line : ndjson.split("\n")) {
            line = line.trim();
            if (line.isEmpty()) continue;
            try {
                points.add(mapper.readTree(line));
            } catch (Exception ignored) {
                // skip malformed lines — same behavior as result-parser.js
            }
        }
        return points;
    }

    /** Aggregate all "Point" type entries per metric name → list of values */
    private Map<String, List<Double>> aggregateMetrics(List<JsonNode> points) {
        Map<String, List<Double>> result = new HashMap<>();
        for (JsonNode dp : points) {
            if (!"Point".equals(dp.path("type").asText())) continue;
            String metric = dp.path("metric").asText();
            double value = dp.path("data").path("value").asDouble(0);
            result.computeIfAbsent(metric, k -> new ArrayList<>()).add(value);
        }
        return result;
    }

    /** Group values by endpoint tag for a given metric */
    private Map<String, List<Double>> groupByEndpoint(List<JsonNode> points, String metric) {
        Map<String, List<Double>> result = new HashMap<>();
        for (JsonNode dp : points) {
            if (!"Point".equals(dp.path("type").asText())) continue;
            if (!metric.equals(dp.path("metric").asText())) continue;
            String endpoint = dp.path("data").path("tags").path("endpoint").asText("unknown");
            double value = dp.path("data").path("value").asDouble(0);
            result.computeIfAbsent(endpoint, k -> new ArrayList<>()).add(value);
        }
        return result;
    }

    /** Calculate percentile — matches JS logic in result-parser.js exactly */
    private double percentile(List<Double> values, int p) {
        if (values == null || values.isEmpty()) return 0;
        List<Double> sorted = values.stream().sorted().collect(Collectors.toList());
        int idx = (int) Math.ceil((p / 100.0) * sorted.size()) - 1;
        return sorted.get(Math.max(0, idx));
    }

    private double avg(List<Double> values) {
        if (values == null || values.isEmpty()) return 0;
        return values.stream().mapToDouble(Double::doubleValue).average().orElse(0);
    }

    /** Evaluate threshold: return PASS/FAIL/SKIP */
    private String evaluateThreshold(Map<String, List<Double>> metrics,
                                      String metricName, String stat, double threshold, String operator) {
        List<Double> vals = metrics.get(metricName);
        if (vals == null || vals.isEmpty()) return "SKIP";
        double actual;
        switch (stat) {
            case "p95": actual = percentile(vals, 95); break;
            case "p99": actual = percentile(vals, 99); break;
            case "avg": actual = avg(vals); break;
            case "max": actual = vals.stream().mapToDouble(Double::doubleValue).max().orElse(0); break;
            default: return "SKIP";
        }
        boolean pass = "<".equals(operator) ? actual < threshold : actual > threshold;
        return pass ? "PASS" : "FAIL";
    }

    // =========================================================================
    // 1. NDJSON PARSING
    // =========================================================================

    @Nested
    @DisplayName("1. NDJSON Parser")
    class NdjsonParserTest {

        @Test
        @DisplayName("Harus parse baris NDJSON valid menjadi list JsonNode")
        void shouldParseValidNdjsonLines() {
            List<ObjectNode> points = List.of(
                    makePoint("http_req_duration", 123.4, "login"),
                    makePoint("http_req_duration", 200.5, "register"),
                    makePoint("http_req_failed", 0.0, "login")
            );

            List<JsonNode> result = parseNdjson(toNdjson(points));

            assertThat(result).hasSize(3);
            assertThat(result.get(0).path("metric").asText()).isEqualTo("http_req_duration");
            assertThat(result.get(2).path("metric").asText()).isEqualTo("http_req_failed");
        }

        @Test
        @DisplayName("Harus skip baris kosong tanpa error")
        void shouldSkipEmptyLines() {
            String ndjson = makePoint("http_req_duration", 100.0).toString()
                    + "\n\n"
                    + makePoint("http_req_duration", 200.0).toString()
                    + "\n";

            List<JsonNode> result = parseNdjson(ndjson);

            assertThat(result).hasSize(2);
        }

        @Test
        @DisplayName("Harus skip baris malformed JSON tanpa throw exception")
        void shouldSkipMalformedJsonLines() {
            String ndjson = makePoint("http_req_duration", 100.0).toString()
                    + "\n{INVALID JSON HERE}\n"
                    + makePoint("http_req_failed", 0.0).toString();

            List<JsonNode> result = parseNdjson(ndjson);

            // Hanya 2 baris valid yang berhasil di-parse
            assertThat(result).hasSize(2);
        }

        @Test
        @DisplayName("Harus return empty list untuk input kosong")
        void shouldReturnEmptyListForEmptyInput() {
            List<JsonNode> result = parseNdjson("");
            assertThat(result).isEmpty();
        }

        @Test
        @DisplayName("Harus skip non-Point type entries saat aggregasi")
        void shouldSkipNonPointTypeEntries() {
            ObjectNode metaEntry = mapper.createObjectNode();
            metaEntry.put("type", "Metric");
            metaEntry.put("metric", "http_req_duration");

            String ndjson = metaEntry.toString() + "\n"
                    + makePoint("http_req_duration", 150.0).toString();

            List<JsonNode> points = parseNdjson(ndjson);
            Map<String, List<Double>> metrics = aggregateMetrics(points);

            // Hanya Point yang masuk ke agregasi
            assertThat(metrics.get("http_req_duration")).hasSize(1);
            assertThat(metrics.get("http_req_duration").get(0)).isEqualTo(150.0);
        }

        @Test
        @DisplayName("Harus parse nilai timestamp dari field data.time")
        void shouldParseTimestampFromDataTime() {
            String ndjson = makePoint("http_reqs", 1.0, "login").toString();

            List<JsonNode> result = parseNdjson(ndjson);

            assertThat(result.get(0).path("data").path("time").asText())
                    .isEqualTo("2024-01-01T00:00:00Z");
        }
    }

    // =========================================================================
    // 2. METRIC AGGREGATION
    // =========================================================================

    @Nested
    @DisplayName("2. Metric Aggregator")
    class MetricAggregatorTest {

        @Test
        @DisplayName("Harus aggregate multiple data points per metric name")
        void shouldAggregateMultiplePointsPerMetric() {
            List<ObjectNode> points = List.of(
                    makePoint("http_req_duration", 100.0),
                    makePoint("http_req_duration", 200.0),
                    makePoint("http_req_duration", 300.0),
                    makePoint("http_req_failed", 0.0),
                    makePoint("http_req_failed", 1.0)
            );

            Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));

            assertThat(metrics).containsKey("http_req_duration");
            assertThat(metrics).containsKey("http_req_failed");
            assertThat(metrics.get("http_req_duration")).hasSize(3);
            assertThat(metrics.get("http_req_failed")).hasSize(2);
        }

        @Test
        @DisplayName("Harus aggregate semua 7 WalkGuide custom metrics")
        void shouldAggregateAllWalkGuideCustomMetrics() {
            List<String> wgMetrics = List.of(
                    "walkguide_auth_latency_ms",
                    "walkguide_location_latency_ms",
                    "walkguide_obstacle_latency_ms",
                    "walkguide_sos_latency_ms",
                    "walkguide_notif_latency_ms",
                    "walkguide_timeline_latency_ms",
                    "walkguide_pairing_latency_ms"
            );

            List<ObjectNode> points = wgMetrics.stream()
                    .map(m -> makePoint(m, 150.0))
                    .collect(Collectors.toList());

            Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));

            for (String m : wgMetrics) {
                assertThat(metrics).containsKey(m);
                assertThat(metrics.get(m)).isNotEmpty();
            }
        }

        @Test
        @DisplayName("Harus accumulate nilai ke list yang sama untuk metric yang sama")
        void shouldAccumulateValuesForSameMetric() {
            List<ObjectNode> points = new ArrayList<>();
            for (int i = 1; i <= 10; i++) {
                points.add(makePoint("walkguide_sos_latency_ms", i * 10.0));
            }

            Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));

            assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
            assertThat(metrics.get("walkguide_sos_latency_ms"))
                    .containsExactlyInAnyOrder(10.0, 20.0, 30.0, 40.0, 50.0,
                                               60.0, 70.0, 80.0, 90.0, 100.0);
        }

        @Test
        @DisplayName("Harus handle nilai 0 sebagai valid data point")
        void shouldHandleZeroValueAsValidDataPoint() {
            List<ObjectNode> points = List.of(
                    makePoint("http_req_failed", 0.0),
                    makePoint("http_req_failed", 0.0),
                    makePoint("http_req_failed", 1.0)
            );

            Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));

            assertThat(metrics.get("http_req_failed")).containsExactly(0.0, 0.0, 1.0);
        }
    }

    // =========================================================================
    // 3. STATISTICAL CALCULATIONS
    // =========================================================================

    @Nested
    @DisplayName("3. Statistical Calculator")
    class StatCalculatorTest {

        @Test
        @DisplayName("avg — harus hitung rata-rata dengan benar")
        void avgShouldCalculateCorrectly() {
            assertThat(avg(List.of(100.0, 200.0, 300.0))).isEqualTo(200.0);
        }

        @Test
        @DisplayName("avg — harus return 0 untuk list kosong")
        void avgShouldReturnZeroForEmptyList() {
            assertThat(avg(Collections.emptyList())).isEqualTo(0.0);
        }

        @Test
        @DisplayName("p50 — harus return nilai median yang benar")
        void p50ShouldReturnMedian() {
            // sorted: [1,2,3,4,5] → idx = ceil(0.5*5)-1 = 2 → value = 3
            List<Double> values = List.of(5.0, 1.0, 3.0, 2.0, 4.0);
            assertThat(percentile(values, 50)).isEqualTo(3.0);
        }

        @Test
        @DisplayName("p95 — harus return 95th percentile dengan benar (10 nilai)")
        void p95ShouldCalculateCorrectly() {
            // Sorted: 10,20,...,100 → idx = ceil(0.95*10)-1 = 10-1 = 9 → value = 100
            List<Double> values = new ArrayList<>();
            for (int i = 1; i <= 10; i++) values.add(i * 10.0);
            Collections.shuffle(values);

            assertThat(percentile(values, 95)).isEqualTo(100.0);
        }

        @Test
        @DisplayName("p95 — harus return 95th percentile dengan benar (100 nilai)")
        void p95ShouldCalculateCorrectlyWith100Values() {
            // 1..100 → p95 = value at idx ceil(95)-1 = 94 → value = 95
            List<Double> values = new ArrayList<>();
            for (int i = 1; i <= 100; i++) values.add((double) i);
            Collections.shuffle(values);

            assertThat(percentile(values, 95)).isEqualTo(95.0);
        }

        @Test
        @DisplayName("p99 — harus return 99th percentile dengan benar")
        void p99ShouldCalculateCorrectly() {
            List<Double> values = new ArrayList<>();
            for (int i = 1; i <= 100; i++) values.add((double) i);

            assertThat(percentile(values, 99)).isEqualTo(99.0);
        }

        @Test
        @DisplayName("percentile — harus return 0 untuk list kosong atau null")
        void percentileShouldReturnZeroForEmpty() {
            assertThat(percentile(Collections.emptyList(), 95)).isEqualTo(0.0);
            assertThat(percentile(null, 95)).isEqualTo(0.0);
        }

        @Test
        @DisplayName("percentile — harus return nilai itu sendiri untuk single element")
        void percentileShouldReturnSingleValue() {
            List<Double> values = List.of(42.0);
            assertThat(percentile(values, 50)).isEqualTo(42.0);
            assertThat(percentile(values, 95)).isEqualTo(42.0);
            assertThat(percentile(values, 99)).isEqualTo(42.0);
        }

        @Test
        @DisplayName("p99 >= p95 >= p50 >= min dan p99 <= max (monotonic ordering)")
        void statisticalOrderingShouldBeMonotonic() {
            List<Double> values = List.of(10.0, 50.0, 30.0, 80.0, 200.0, 150.0, 90.0, 45.0, 175.0, 60.0);

            double p50 = percentile(values, 50);
            double p95 = percentile(values, 95);
            double p99 = percentile(values, 99);
            double min = values.stream().mapToDouble(Double::doubleValue).min().orElse(0);
            double max = values.stream().mapToDouble(Double::doubleValue).max().orElse(0);

            assertThat(p95).isGreaterThanOrEqualTo(p50);
            assertThat(p99).isGreaterThanOrEqualTo(p95);
            assertThat(p50).isGreaterThanOrEqualTo(min);
            assertThat(p99).isLessThanOrEqualTo(max);
        }
    }

    // =========================================================================
    // 4. THRESHOLD EVALUATION
    // =========================================================================

    @Nested
    @DisplayName("4. Threshold Evaluator")
    class ThresholdEvaluatorTest {

        @Test
        @DisplayName("PASS — p95 http_req_duration < 500ms saat semua nilai di bawah threshold")
        void shouldPassWhenP95BelowThreshold() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_duration",
                    List.of(100.0, 150.0, 200.0, 300.0, 400.0, 120.0, 180.0, 250.0, 350.0, 450.0));

            assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
                    .isEqualTo("PASS");
        }

        @Test
        @DisplayName("FAIL — p95 http_req_duration >= 500ms saat banyak nilai tinggi")
        void shouldFailWhenP95ExceedsThreshold() {
            Map<String, List<Double>> metrics = new HashMap<>();
            // p95 = 900ms
            metrics.put("http_req_duration",
                    List.of(100.0, 200.0, 300.0, 400.0, 500.0, 600.0, 700.0, 800.0, 900.0, 1000.0));

            assertThat(evaluateThreshold(metrics, "http_req_duration", "p95", 500, "<"))
                    .isEqualTo("FAIL");
        }

        @Test
        @DisplayName("PASS — SOS p95 < 200ms (threshold paling kritis)")
        void shouldPassSosThresholdWithFastResponses() {
            Map<String, List<Double>> metrics = new HashMap<>();
            List<Double> sosTimes = new ArrayList<>();
            for (int i = 0; i < 20; i++) sosTimes.add(50.0 + i * 5); // 50ms - 145ms
            metrics.put("walkguide_sos_latency_ms", sosTimes);

            assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
                    .isEqualTo("PASS");
        }

        @Test
        @DisplayName("FAIL — SOS p95 >= 200ms saat respons lambat")
        void shouldFailSosThresholdWithSlowResponses() {
            Map<String, List<Double>> metrics = new HashMap<>();
            List<Double> sosTimes = new ArrayList<>();
            for (int i = 0; i < 20; i++) sosTimes.add(100.0 + i * 20); // hingga 480ms
            metrics.put("walkguide_sos_latency_ms", sosTimes);

            assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms", "p95", 200, "<"))
                    .isEqualTo("FAIL");
        }

        @Test
        @DisplayName("PASS — error rate < 1% saat semua request sukses")
        void shouldPassErrorRateWithZeroFailures() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_failed", Collections.nCopies(100, 0.0));

            assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
                    .isEqualTo("PASS");
        }

        @Test
        @DisplayName("FAIL — error rate >= 1% saat banyak request gagal")
        void shouldFailErrorRateWithHighFailures() {
            Map<String, List<Double>> metrics = new HashMap<>();
            // 10% error rate
            List<Double> failed = new ArrayList<>(Collections.nCopies(90, 0.0));
            failed.addAll(Collections.nCopies(10, 1.0));
            metrics.put("http_req_failed", failed);

            assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<"))
                    .isEqualTo("FAIL");
        }

        @Test
        @DisplayName("SKIP — harus return SKIP untuk metric yang tidak ada di data")
        void shouldReturnSkipForMissingMetric() {
            Map<String, List<Double>> metrics = new HashMap<>();

            assertThat(evaluateThreshold(metrics, "walkguide_jvm_heap_used_mb", "avg", 512, "<"))
                    .isEqualTo("SKIP");
        }

        @Test
        @DisplayName("Harus evaluate semua 8 threshold checks dari result-parser.js dan semua PASS")
        void shouldEvaluateAllEightThresholdChecksAndAllPass() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_duration",             buildRange(20, 50, 400));
            metrics.put("http_req_failed",               Collections.nCopies(100, 0.0));
            metrics.put("walkguide_sos_latency_ms",      buildRange(20, 50, 150));
            metrics.put("walkguide_location_latency_ms", buildRange(20, 50, 250));
            metrics.put("walkguide_obstacle_latency_ms", buildRange(20, 50, 350));
            metrics.put("walkguide_auth_latency_ms",     buildRange(20, 100, 700));
            metrics.put("walkguide_timeline_latency_ms", buildRange(20, 200, 900));
            metrics.put("walkguide_notif_latency_ms",    buildRange(20, 50, 450));

            // Mirror dari result-parser.js thresholdChecks array
            assertThat(evaluateThreshold(metrics, "http_req_duration",             "p95", 500,  "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "http_req_failed",               "avg", 0.01, "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_sos_latency_ms",      "p95", 200,  "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_location_latency_ms", "p95", 300,  "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_obstacle_latency_ms", "p95", 400,  "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_auth_latency_ms",     "p95", 800,  "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_timeline_latency_ms", "p95", 1000, "<")).isEqualTo("PASS");
            assertThat(evaluateThreshold(metrics, "walkguide_notif_latency_ms",    "p95", 500,  "<")).isEqualTo("PASS");
        }

        private List<Double> buildRange(int count, double min, double max) {
            List<Double> list = new ArrayList<>();
            double step = (max - min) / Math.max(1, count - 1);
            for (int i = 0; i < count; i++) list.add(min + step * i);
            return list;
        }
    }

    // =========================================================================
    // 5. KEY METRIC EXTRACTION (5 wajib Pillar 3)
    // =========================================================================

    @Nested
    @DisplayName("5. Key Metric Extractor (5 Pillar 3 Metrics)")
    class KeyMetricExtractorTest {

        @Test
        @DisplayName("Metric 1 — Throughput: dihitung dari total req / durasi detik")
        void shouldCalculateThroughput() {
            // 1000 requests dalam 20 detik = 50 req/s
            int totalRequests = 1000;
            long durationMs   = 20_000L;
            double throughput = (double) totalRequests / (durationMs / 1000.0);

            assertThat(throughput).isEqualTo(50.0);
            assertThat(String.format("%.1f req/s", throughput)).isEqualTo("50.0 req/s");
        }

        @Test
        @DisplayName("Metric 2 — p95 Latency: extract dari http_req_duration p95")
        void shouldExtractP95LatencyFromHttpReqDuration() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_duration", Collections.nCopies(100, 200.0));

            double p95 = percentile(metrics.get("http_req_duration"), 95);

            assertThat(p95).isEqualTo(200.0);
            assertThat(String.format("%.2f ms", p95)).isEqualTo("200.00 ms");
        }

        @Test
        @DisplayName("Metric 3 — Error Rate: avg http_req_failed * 100 = persentase")
        void shouldCalculateErrorRatePercentage() {
            Map<String, List<Double>> metrics = new HashMap<>();
            // 95 sukses + 5 gagal = 5% error
            List<Double> failed = new ArrayList<>(Collections.nCopies(95, 0.0));
            failed.addAll(Collections.nCopies(5, 1.0));
            metrics.put("http_req_failed", failed);

            double errorRatePct = avg(metrics.get("http_req_failed")) * 100;

            assertThat(errorRatePct).isCloseTo(5.0, within(0.01));
            assertThat(errorRatePct < 1.0).isFalse(); // FAIL
        }

        @Test
        @DisplayName("Metric 3 — Error Rate PASS saat < 1%")
        void errorRateShouldPassWhenBelow1Percent() {
            Map<String, List<Double>> metrics = new HashMap<>();
            List<Double> failed = new ArrayList<>(Collections.nCopies(995, 0.0));
            failed.addAll(Collections.nCopies(5, 1.0)); // 0.5% error
            metrics.put("http_req_failed", failed);

            double errorRatePct = avg(metrics.get("http_req_failed")) * 100;
            String passFail = errorRatePct < 1.0 ? "PASS" : "FAIL";

            assertThat(passFail).isEqualTo("PASS");
            assertThat(errorRatePct).isCloseTo(0.5, within(0.01));
        }

        @Test
        @DisplayName("Metric 4 — DB Query Time: avg(location p95, obstacle p95)")
        void shouldEstimateDbQueryTimeFromWriteEndpoints() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("walkguide_location_latency_ms", Collections.nCopies(10, 250.0));
            metrics.put("walkguide_obstacle_latency_ms", Collections.nCopies(10, 350.0));

            double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);
            double obstacleP95 = percentile(metrics.get("walkguide_obstacle_latency_ms"), 95);
            double dbEstimate  = (locationP95 + obstacleP95) / 2;

            assertThat(dbEstimate).isEqualTo(300.0);
            assertThat(String.format("%.2f ms", dbEstimate)).isEqualTo("300.00 ms");
        }

        @Test
        @DisplayName("Metric 5 — JVM Heap: avg dari walkguide_jvm_heap_used_mb gauge readings")
        void shouldExtractJvmHeapFromActuatorGauge() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("walkguide_jvm_heap_used_mb", List.of(256.0, 280.0, 310.0, 295.0, 270.0));

            double heapAvg = avg(metrics.get("walkguide_jvm_heap_used_mb"));

            assertThat(heapAvg).isCloseTo(282.2, within(0.1));
            assertThat(String.format("%.2f MB", heapAvg)).isEqualTo("282.20 MB");
        }
    }

    // =========================================================================
    // 6. WALKGUIDE-SPECIFIC METRICS
    // =========================================================================

    @Nested
    @DisplayName("6. WalkGuide Endpoint Metrics")
    class WalkGuideMetricsTest {

        @Test
        @DisplayName("SOS p95 harus lebih rendah dari auth p95 (life-safety priority)")
        void sosLatencyShouldBeLowerThanAuthLatency() {
            Map<String, List<Double>> metrics = buildRealisticMetrics();

            double sosP95  = percentile(metrics.get("walkguide_sos_latency_ms"), 95);
            double authP95 = percentile(metrics.get("walkguide_auth_latency_ms"), 95);

            assertThat(sosP95).isLessThan(authP95);
            assertThat(sosP95).isLessThan(200.0); // SOS threshold wajib
        }

        @Test
        @DisplayName("Location p95 harus < 300ms karena high-frequency endpoint")
        void locationLatencyShouldMeetThreshold() {
            Map<String, List<Double>> metrics = buildRealisticMetrics();

            double p95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);

            assertThat(p95).isLessThan(300.0);
        }

        @Test
        @DisplayName("Timeline p95 boleh lebih tinggi dari location p95 (analytics vs real-time)")
        void timelineLatencyCanBeHigherThanLocationLatency() {
            Map<String, List<Double>> metrics = buildRealisticMetrics();

            double timelineP95 = percentile(metrics.get("walkguide_timeline_latency_ms"), 95);
            double locationP95 = percentile(metrics.get("walkguide_location_latency_ms"), 95);

            assertThat(timelineP95).isGreaterThan(locationP95);
            assertThat(timelineP95).isLessThan(1000.0);
        }

        @Test
        @DisplayName("Semua 7 WalkGuide metrics harus terpopulasi dengan p95, p99, avg")
        void allSevenWalkGuideMetricsShouldBePopulated() {
            Map<String, List<Double>> metrics = buildRealisticMetrics();
            Map<String, String> wgMetricMap = new LinkedHashMap<>();
            wgMetricMap.put("authLatency",     "walkguide_auth_latency_ms");
            wgMetricMap.put("locationLatency", "walkguide_location_latency_ms");
            wgMetricMap.put("obstacleLatency", "walkguide_obstacle_latency_ms");
            wgMetricMap.put("sosLatency",      "walkguide_sos_latency_ms");
            wgMetricMap.put("notifLatency",    "walkguide_notif_latency_ms");
            wgMetricMap.put("timelineLatency", "walkguide_timeline_latency_ms");
            wgMetricMap.put("pairingLatency",  "walkguide_pairing_latency_ms");

            Map<String, Map<String, String>> walkguideMetrics = new HashMap<>();
            for (Map.Entry<String, String> e : wgMetricMap.entrySet()) {
                List<Double> vals = metrics.get(e.getValue());
                if (vals != null && !vals.isEmpty()) {
                    Map<String, String> stats = new HashMap<>();
                    stats.put("p95", String.format("%.2f ms", percentile(vals, 95)));
                    stats.put("p99", String.format("%.2f ms", percentile(vals, 99)));
                    stats.put("avg", String.format("%.2f ms", avg(vals)));
                    walkguideMetrics.put(e.getKey(), stats);
                }
            }

            assertThat(walkguideMetrics).hasSize(7);
            assertThat(walkguideMetrics).containsKeys(
                    "authLatency", "locationLatency", "obstacleLatency",
                    "sosLatency", "notifLatency", "timelineLatency", "pairingLatency");
            // Setiap metric harus punya p95, p99, avg
            for (Map<String, String> stats : walkguideMetrics.values()) {
                assertThat(stats).containsKeys("p95", "p99", "avg");
                assertThat(stats.get("p95")).endsWith("ms");
            }
        }

        private Map<String, List<Double>> buildRealisticMetrics() {
            Map<String, List<Double>> m = new HashMap<>();
            m.put("walkguide_sos_latency_ms",       buildRange(50, 50, 150));
            m.put("walkguide_location_latency_ms",  buildRange(50, 80, 260));
            m.put("walkguide_obstacle_latency_ms",  buildRange(50, 100, 360));
            m.put("walkguide_auth_latency_ms",      buildRange(50, 200, 750));
            m.put("walkguide_notif_latency_ms",     buildRange(50, 100, 450));
            m.put("walkguide_timeline_latency_ms",  buildRange(50, 300, 900));
            m.put("walkguide_pairing_latency_ms",   buildRange(50, 150, 550));
            return m;
        }

        private List<Double> buildRange(int count, double min, double max) {
            List<Double> list = new ArrayList<>();
            double step = (max - min) / Math.max(1, count - 1);
            for (int i = 0; i < count; i++) list.add(min + step * i);
            return list;
        }
    }

    // =========================================================================
    // 7. ENDPOINT BREAKDOWN
    // =========================================================================

    @Nested
    @DisplayName("7. Endpoint Breakdown (byEndpoint grouping)")
    class EndpointBreakdownTest {

        @Test
        @DisplayName("Harus group metric values berdasarkan endpoint tag")
        void shouldGroupValuesByEndpointTag() {
            List<ObjectNode> points = List.of(
                    makePoint("http_req_duration", 100.0, "login"),
                    makePoint("http_req_duration", 150.0, "login"),
                    makePoint("http_req_duration", 200.0, "register"),
                    makePoint("http_req_duration", 300.0, "register"),
                    makePoint("http_req_duration", 80.0,  "ping")
            );

            Map<String, List<Double>> byEndpoint = groupByEndpoint(
                    parseNdjson(toNdjson(points)), "http_req_duration");

            assertThat(byEndpoint).containsKeys("login", "register", "ping");
            assertThat(byEndpoint.get("login")).containsExactly(100.0, 150.0);
            assertThat(byEndpoint.get("register")).containsExactly(200.0, 300.0);
            assertThat(byEndpoint.get("ping")).containsExactly(80.0);
        }

        @Test
        @DisplayName("Harus support semua WalkGuide endpoint tags dari api-client.js")
        void shouldSupportAllWalkGuideEndpointTags() {
            List<String> endpoints = List.of(
                    "login", "register", "refresh_token", "logout",
                    "pairing_invite", "pairing_respond", "pairing_status",
                    "location_update", "obstacle_log", "sos_trigger",
                    "notifications", "send_notification", "unread_count",
                    "guardian_dashboard", "location_history"
            );

            List<ObjectNode> points = endpoints.stream()
                    .map(ep -> makePoint("http_req_duration", 200.0, ep))
                    .collect(Collectors.toList());

            Map<String, List<Double>> byEndpoint = groupByEndpoint(
                    parseNdjson(toNdjson(points)), "http_req_duration");

            for (String ep : endpoints) {
                assertThat(byEndpoint).containsKey(ep);
            }
        }

        @Test
        @DisplayName("p95 per endpoint: sos_trigger harus lebih cepat dari login")
        void p95PerEndpointShouldShowSosIsFasterThanLogin() {
            List<ObjectNode> points = new ArrayList<>();
            for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10, "login"));
            for (int i = 0; i < 10; i++) points.add(makePoint("http_req_duration", 50.0 + i * 10, "sos_trigger"));

            Map<String, List<Double>> byEndpoint = groupByEndpoint(
                    parseNdjson(toNdjson(points)), "http_req_duration");

            double loginP95 = percentile(byEndpoint.get("login"), 95);
            double sosP95   = percentile(byEndpoint.get("sos_trigger"), 95);

            assertThat(loginP95).isGreaterThan(sosP95);
            assertThat(sosP95).isLessThan(200.0);
            assertThat(loginP95).isLessThan(800.0);
        }

        @Test
        @DisplayName("Endpoint tanpa tag harus masuk ke bucket 'unknown'")
        void shouldGroupUntaggedEndpointAsUnknown() {
            List<ObjectNode> points = List.of(
                    makePoint("http_req_duration", 200.0),        // → unknown
                    makePoint("http_req_duration", 300.0, "login")
            );

            Map<String, List<Double>> byEndpoint = groupByEndpoint(
                    parseNdjson(toNdjson(points)), "http_req_duration");

            assertThat(byEndpoint).containsKey("unknown");
            assertThat(byEndpoint).containsKey("login");
        }
    }

    // =========================================================================
    // 8. EDGE CASES
    // =========================================================================

    @Nested
    @DisplayName("8. Edge Cases")
    class EdgeCasesTest {

        @Test
        @DisplayName("Harus handle NDJSON dengan ribuan data points tanpa error")
        void shouldHandleLargeNdjsonInput() {
            List<ObjectNode> points = new ArrayList<>();
            for (int i = 0; i < 5000; i++) {
                points.add(makePoint("http_req_duration", 100.0 + (i % 400)));
            }

            List<JsonNode> parsed = parseNdjson(toNdjson(points));
            Map<String, List<Double>> metrics = aggregateMetrics(parsed);

            assertThat(parsed).hasSize(5000);
            assertThat(metrics.get("http_req_duration")).hasSize(5000);
            double p95 = percentile(metrics.get("http_req_duration"), 95);
            assertThat(p95).isGreaterThan(0).isLessThan(600);
        }

        @Test
        @DisplayName("Semua baris malformed harus menghasilkan empty metrics tanpa exception")
        void shouldReturnEmptyMetricsForAllMalformedInput() {
            String ndjson = "{INVALID}\n{ALSO INVALID}\nnot json at all";

            List<JsonNode> parsed = parseNdjson(ndjson);
            Map<String, List<Double>> metrics = aggregateMetrics(parsed);

            assertThat(parsed).isEmpty();
            assertThat(metrics).isEmpty();
        }

        @Test
        @DisplayName("Single data point — semua percentile harus return nilai itu sendiri")
        void singleDataPointShouldReturnItselfForAllPercentiles() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_duration", List.of(250.0));

            assertThat(percentile(metrics.get("http_req_duration"), 50)).isEqualTo(250.0);
            assertThat(percentile(metrics.get("http_req_duration"), 95)).isEqualTo(250.0);
            assertThat(percentile(metrics.get("http_req_duration"), 99)).isEqualTo(250.0);
        }

        @Test
        @DisplayName("Threshold SKIP tidak boleh menyebabkan NullPointerException")
        void thresholdSkipShouldNotThrowNPE() {
            Map<String, List<Double>> emptyMetrics = new HashMap<>();

            assertThatNoException().isThrownBy(() -> {
                String r1 = evaluateThreshold(emptyMetrics, "http_req_duration",        "p95", 500, "<");
                String r2 = evaluateThreshold(emptyMetrics, "walkguide_sos_latency_ms", "p95", 200, "<");
                String r3 = evaluateThreshold(emptyMetrics, "walkguide_jvm_heap_used_mb","avg", 512, "<");
                assertThat(r1).isEqualTo("SKIP");
                assertThat(r2).isEqualTo("SKIP");
                assertThat(r3).isEqualTo("SKIP");
            });
        }

        @Test
        @DisplayName("Mixed metric types dalam satu stream harus ter-aggregate dengan benar")
        void shouldAggregateMixedMetricTypesCorrectly() {
            List<ObjectNode> points = new ArrayList<>();
            for (int i = 0; i < 20; i++) points.add(makePoint("http_req_duration", 100.0 + i * 10));
            for (int i = 0; i < 20; i++) points.add(makePoint("http_req_failed", i < 19 ? 0.0 : 1.0));
            for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_sos_latency_ms", 50.0 + i * 10));
            for (int i = 0; i < 10; i++) points.add(makePoint("walkguide_location_latency_ms", 80.0 + i * 15));
            for (int i = 0; i < 5;  i++) points.add(makePoint("walkguide_jvm_heap_used_mb", 250.0 + i * 10));

            Collections.shuffle(points);
            Map<String, List<Double>> metrics = aggregateMetrics(parseNdjson(toNdjson(points)));

            assertThat(metrics.get("http_req_duration")).hasSize(20);
            assertThat(metrics.get("http_req_failed")).hasSize(20);
            assertThat(metrics.get("walkguide_sos_latency_ms")).hasSize(10);
            assertThat(metrics.get("walkguide_location_latency_ms")).hasSize(10);
            assertThat(metrics.get("walkguide_jvm_heap_used_mb")).hasSize(5);
        }

        @Test
        @DisplayName("Error rate 0% harus selalu PASS")
        void zeroErrorRateShouldAlwaysPass() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_failed", Collections.nCopies(1000, 0.0));

            assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("PASS");
            assertThat(avg(metrics.get("http_req_failed"))).isEqualTo(0.0);
        }

        @Test
        @DisplayName("Error rate 100% harus selalu FAIL")
        void hundredPercentErrorRateShouldFail() {
            Map<String, List<Double>> metrics = new HashMap<>();
            metrics.put("http_req_failed", Collections.nCopies(100, 1.0));

            assertThat(evaluateThreshold(metrics, "http_req_failed", "avg", 0.01, "<")).isEqualTo("FAIL");
        }
    }
}