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.TopError;
import com.cameleer.server.core.storage.StatsStore.PunchcardCell;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;

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

@Testcontainers
class ClickHouseStatsStoreIT {

    @Container
    static final ClickHouseContainer clickhouse =
            new ClickHouseContainer("clickhouse/clickhouse-server:24.12");

    private JdbcTemplate jdbc;
    private ClickHouseStatsStore store;

    // base time: 2026-03-31T10:00:00Z (a Tuesday)
    private static final Instant BASE = Instant.parse("2026-03-31T10:00:00Z");

    @BeforeAll
    static void pinJvmUtc() {
        // ClickHouse JDBC driver 0.9.x formats java.sql.Timestamp via its
        // toString(), which uses JVM default TZ. On a non-UTC dev JVM
        // (e.g. CEST), timestamps were being sent to CH off by the TZ offset
        // even though the CH server TZ is UTC. Pinning JVM default to UTC
        // for this test class makes inserts round-trip to the UTC-typed
        // bucket column predictably.
        java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC"));
    }

    @BeforeEach
    void setUp() throws Exception {
        HikariDataSource ds = new HikariDataSource();
        // Pin driver to UTC so Timestamp binding doesn't depend on JVM default TZ.
        ds.setJdbcUrl(clickhouse.getJdbcUrl() + "?use_server_time_zone=false&use_time_zone=UTC");
        ds.setUsername(clickhouse.getUsername());
        ds.setPassword(clickhouse.getPassword());

        jdbc = new JdbcTemplate(ds);

        ClickHouseTestHelper.executeInitSql(jdbc);

        // Truncate base tables
        jdbc.execute("TRUNCATE TABLE executions");
        jdbc.execute("TRUNCATE TABLE processor_executions");

        seedTestData();

        store = new ClickHouseStatsStore("default", jdbc);
    }

    private void seedTestData() {
        // 10 executions across 2 apps, 2 routes, spanning 5 minutes
        // app-1, route-a: 4 COMPLETED (200ms, 300ms, 400ms, 500ms)
        insertExecution("exec-01", BASE.plusSeconds(0), "app-1", "route-a", "agent-1",
                "COMPLETED", 200L, "", "");
        insertExecution("exec-02", BASE.plusSeconds(60), "app-1", "route-a", "agent-1",
                "COMPLETED", 300L, "", "");
        insertExecution("exec-03", BASE.plusSeconds(120), "app-1", "route-a", "agent-1",
                "COMPLETED", 400L, "", "");
        insertExecution("exec-04", BASE.plusSeconds(180), "app-1", "route-a", "agent-1",
                "COMPLETED", 500L, "", "");

        // app-1, route-a: 2 FAILED (100ms, 150ms) with error_type="NPE"
        insertExecution("exec-05", BASE.plusSeconds(60), "app-1", "route-a", "agent-1",
                "FAILED", 100L, "NPE", "null ref");
        insertExecution("exec-06", BASE.plusSeconds(120), "app-1", "route-a", "agent-1",
                "FAILED", 150L, "NPE", "null ref");

        // app-1, route-b: 2 COMPLETED (50ms, 60ms)
        insertExecution("exec-07", BASE.plusSeconds(60), "app-1", "route-b", "agent-1",
                "COMPLETED", 50L, "", "");
        insertExecution("exec-08", BASE.plusSeconds(120), "app-1", "route-b", "agent-1",
                "COMPLETED", 60L, "", "");

        // app-2, route-c: 1 COMPLETED (1000ms)
        insertExecution("exec-09", BASE.plusSeconds(60), "app-2", "route-c", "agent-2",
                "COMPLETED", 1000L, "", "");

        // app-2, route-c: 1 RUNNING (null duration)
        insertExecution("exec-10", BASE.plusSeconds(180), "app-2", "route-c", "agent-2",
                "RUNNING", null, "", "");

        // 5 processor records for processor stats testing
        // app-1, route-a, processor_type="to": 3 COMPLETED
        insertProcessor("exec-01", 1, "proc-to-1", "to", BASE.plusSeconds(0),
                "app-1", "route-a", "COMPLETED", 50L);
        insertProcessor("exec-02", 1, "proc-to-2", "to", BASE.plusSeconds(60),
                "app-1", "route-a", "COMPLETED", 80L);
        insertProcessor("exec-03", 1, "proc-to-3", "to", BASE.plusSeconds(120),
                "app-1", "route-a", "COMPLETED", 90L);

        // app-1, route-a, processor_type="log": 2 COMPLETED
        insertProcessor("exec-01", 2, "proc-log-1", "log", BASE.plusSeconds(1),
                "app-1", "route-a", "COMPLETED", 10L);
        insertProcessor("exec-02", 2, "proc-log-2", "log", BASE.plusSeconds(61),
                "app-1", "route-a", "COMPLETED", 15L);
    }

    private void insertExecution(String executionId, Instant startTime, String appId,
                                  String routeId, String instanceId, String status,
                                  Long durationMs, String errorType, String errorMessage) {
        jdbc.update(
                "INSERT INTO executions (tenant_id, execution_id, start_time, route_id, " +
                "instance_id, application_id, status, duration_ms, error_type, error_message) " +
                "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                executionId, Timestamp.from(startTime), routeId, instanceId, appId,
                status, durationMs, errorType, errorMessage);
    }

    private void insertProcessor(String executionId, int seq, String processorId,
                                  String processorType, Instant startTime,
                                  String appId, String routeId, String status,
                                  Long durationMs) {
        jdbc.update(
                "INSERT INTO processor_executions (tenant_id, execution_id, seq, processor_id, " +
                "processor_type, start_time, route_id, application_id, status, duration_ms) " +
                "VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                executionId, seq, processorId, processorType, Timestamp.from(startTime),
                routeId, appId, status, durationMs);
    }

    // ── Stats Tests ──────────────────────────────────────────────────────

    @Test
    void stats_returnsCorrectGlobalTotals() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        ExecutionStats stats = store.stats(from, to, null);

        assertThat(stats.totalCount()).isEqualTo(10);
        assertThat(stats.failedCount()).isEqualTo(2);
        assertThat(stats.activeCount()).isEqualTo(1);
        assertThat(stats.avgDurationMs()).isGreaterThan(0);
        assertThat(stats.p99LatencyMs()).isGreaterThan(0);
    }

    @Test
    void statsForApp_filtersCorrectly() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        ExecutionStats app1 = store.statsForApp(from, to, "app-1", null);
        assertThat(app1.totalCount()).isEqualTo(8);

        ExecutionStats app2 = store.statsForApp(from, to, "app-2", null);
        assertThat(app2.totalCount()).isEqualTo(2);
    }

    @Test
    void statsForRoute_filtersCorrectly() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        ExecutionStats routeA = store.statsForRoute(from, to, "route-a", null, null);
        assertThat(routeA.totalCount()).isEqualTo(6);
    }

    // ── Timeseries Tests ─────────────────────────────────────────────────

    @Test
    void timeseries_returnsBuckets() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        StatsTimeseries ts = store.timeseries(from, to, 5, null);

        assertThat(ts.buckets()).isNotEmpty();
        long totalAcrossBuckets = ts.buckets().stream()
                .mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
        assertThat(totalAcrossBuckets).isEqualTo(10);
    }

    @Test
    void timeseriesForApp_filtersCorrectly() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1", null);

        long totalAcrossBuckets = ts.buckets().stream()
                .mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
        assertThat(totalAcrossBuckets).isEqualTo(8);
    }

    @Test
    void timeseriesGroupedByApp_returnsMap() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByApp(from, to, 5, null);

        assertThat(grouped).containsKeys("app-1", "app-2");
    }

    @Test
    void timeseriesGroupedByRoute_returnsMap() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1", null);

        assertThat(grouped).containsKeys("route-a", "route-b");
    }

    // ── SLA Tests ────────────────────────────────────────────────────────

    @Test
    void slaCompliance_calculatesCorrectly() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        // threshold=250ms: among 9 non-RUNNING executions:
        // compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
        // total non-running: 9
        // compliance = 5/9 * 100 ~ 55.56%
        double sla = store.slaCompliance(from, to, 250, null, null, null);
        assertThat(sla).isBetween(55.0, 56.0);
    }

    // ── Top Errors Tests ─────────────────────────────────────────────────

    @Test
    void topErrors_returnsRankedErrors() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        List<TopError> errors = store.topErrors(from, to, null, null, 10, null);

        assertThat(errors).isNotEmpty();
        assertThat(errors.get(0).errorType()).isEqualTo("NPE");
        assertThat(errors.get(0).count()).isEqualTo(2);
    }

    // ── Active Error Types Test ──────────────────────────────────────────

    @Test
    void activeErrorTypes_countsDistinct() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        int count = store.activeErrorTypes(from, to, "app-1", null);

        assertThat(count).isEqualTo(1); // only "NPE"
    }

    // ── Punchcard Test ───────────────────────────────────────────────────

    @Test
    void punchcard_returnsWeekdayHourCells() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        List<PunchcardCell> cells = store.punchcard(from, to, null, null);

        assertThat(cells).isNotEmpty();
        long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum();
        assertThat(totalCount).isEqualTo(10);
    }

    @Test
    void slaCountsByApp_returnsMap() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        // threshold=250ms
        Map<String, long[]> counts = store.slaCountsByApp(from, to, 250, null);

        assertThat(counts).containsKeys("app-1", "app-2");
        // app-1: 8 total executions, all non-RUNNING
        // compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
        long[] app1 = counts.get("app-1");
        assertThat(app1[0]).isEqualTo(5); // compliant
        assertThat(app1[1]).isEqualTo(8); // total non-running
        // app-2: 1 COMPLETED(1000ms) + 1 RUNNING → 1 non-RUNNING, 0 compliant
        long[] app2 = counts.get("app-2");
        assertThat(app2[0]).isEqualTo(0); // compliant
        assertThat(app2[1]).isEqualTo(1); // total non-running
    }

    @Test
    void slaCountsByRoute_returnsMap() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        Map<String, long[]> counts = store.slaCountsByRoute(from, to, "app-1", 250, null);

        assertThat(counts).containsKeys("route-a", "route-b");
        // route-a: exec-01(200)OK, exec-02(300)NO, exec-03(400)NO, exec-04(500)NO,
        //          exec-05(100)OK, exec-06(150)OK → 3 compliant, 6 total
        long[] routeA = counts.get("route-a");
        assertThat(routeA[0]).isEqualTo(3); // compliant
        assertThat(routeA[1]).isEqualTo(6); // total
        // route-b: exec-07(50)OK, exec-08(60)OK → 2 compliant, 2 total
        long[] routeB = counts.get("route-b");
        assertThat(routeB[0]).isEqualTo(2);
        assertThat(routeB[1]).isEqualTo(2);
    }

    // ── Processor Stats Test ─────────────────────────────────────────────

    @Test
    void statsForProcessor_filtersCorrectly() {
        Instant from = BASE.minusSeconds(60);
        Instant to = BASE.plusSeconds(300);

        ExecutionStats toStats = store.statsForProcessor(from, to, "route-a", "to");
        assertThat(toStats.totalCount()).isEqualTo(3);
        assertThat(toStats.activeCount()).isEqualTo(0); // processor stats have no running_count

        ExecutionStats logStats = store.statsForProcessor(from, to, "route-a", "log");
        assertThat(logStats.totalCount()).isEqualTo(2);
    }
}
