package com.cameleer.server.app.storage;

import com.cameleer.common.graph.NodeType;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.common.graph.RouteNode;
import com.cameleer.server.core.ingestion.TaggedDiagram;
import com.zaxxer.hikari.HikariDataSource;
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.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;

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

@Testcontainers
class ClickHouseDiagramStoreIT {

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

    private JdbcTemplate jdbc;
    private ClickHouseDiagramStore store;

    @BeforeEach
    void setUp() throws Exception {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(clickhouse.getJdbcUrl());
        ds.setUsername(clickhouse.getUsername());
        ds.setPassword(clickhouse.getPassword());

        jdbc = new JdbcTemplate(ds);

        ClickHouseTestHelper.executeInitSql(jdbc);
        jdbc.execute("TRUNCATE TABLE route_diagrams");

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

    // ── Helpers ──────────────────────────────────────────────────────────

    private RouteGraph buildGraph(String routeId, String... nodeIds) {
        RouteGraph graph = new RouteGraph(routeId);
        if (nodeIds.length > 0) {
            RouteNode root = new RouteNode(nodeIds[0], NodeType.ENDPOINT, "from:" + nodeIds[0]);
            for (int i = 1; i < nodeIds.length; i++) {
                root.addChild(new RouteNode(nodeIds[i], NodeType.PROCESSOR, "proc:" + nodeIds[i]));
            }
            graph.setRoot(root);
        }
        return graph;
    }

    private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
        return new TaggedDiagram(instanceId, applicationId, "default", graph);
    }

    // ── Tests ─────────────────────────────────────────────────────────────

    @Test
    void store_insertsNewDiagram() {
        RouteGraph graph = buildGraph("route-1", "node-a", "node-b");
        store.store(tagged("agent-1", "my-app", graph));

        // Allow ReplacingMergeTree to settle
        jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");

        long count = jdbc.queryForObject(
                "SELECT count() FROM route_diagrams WHERE route_id = 'route-1'",
                Long.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void store_duplicateHashIgnored() {
        RouteGraph graph = buildGraph("route-1", "node-a");
        TaggedDiagram diagram = tagged("agent-1", "my-app", graph);

        store.store(diagram);
        store.store(diagram); // same graph → same hash

        jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");

        long count = jdbc.queryForObject(
                "SELECT count() FROM route_diagrams FINAL WHERE route_id = 'route-1'",
                Long.class);
        assertThat(count).isEqualTo(1);
    }

    @Test
    void findByContentHash_returnsGraph() {
        RouteGraph graph = buildGraph("route-2", "node-x");
        graph.setDescription("Test route");
        TaggedDiagram diagram = tagged("agent-2", "app-a", graph);
        store.store(diagram);

        // Compute the expected hash
        String hash = store.findContentHashForRoute("route-2", "agent-2")
                .orElseThrow(() -> new AssertionError("No hash found for route-2/agent-2"));

        Optional<RouteGraph> result = store.findByContentHash(hash);

        assertThat(result).isPresent();
        assertThat(result.get().getRouteId()).isEqualTo("route-2");
        assertThat(result.get().getDescription()).isEqualTo("Test route");
    }

    @Test
    void findByContentHash_returnsEmptyForUnknownHash() {
        Optional<RouteGraph> result = store.findByContentHash("nonexistent-hash-000");
        assertThat(result).isEmpty();
    }

    @Test
    void findContentHashForRoute_returnsMostRecent() throws InterruptedException {
        RouteGraph graphV1 = buildGraph("route-3", "node-1");
        graphV1.setDescription("v1");
        RouteGraph graphV2 = buildGraph("route-3", "node-1", "node-2");
        graphV2.setDescription("v2");

        store.store(tagged("agent-1", "my-app", graphV1));
        // Small delay to ensure different created_at timestamps
        Thread.sleep(10);
        store.store(tagged("agent-1", "my-app", graphV2));

        Optional<String> hashOpt = store.findContentHashForRoute("route-3", "agent-1");
        assertThat(hashOpt).isPresent();

        // The hash should correspond to graphV2 (the most recent)
        String expectedHash = ClickHouseDiagramStore.sha256Hex(
                store.findByContentHash(hashOpt.get())
                        .map(g -> {
                            try {
                                return new com.fasterxml.jackson.databind.ObjectMapper()
                                        .registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule())
                                        .writeValueAsString(g);
                            } catch (Exception e) {
                                throw new RuntimeException(e);
                            }
                        })
                        .orElseThrow());

        assertThat(hashOpt.get()).isEqualTo(expectedHash);

        // Verify retrieved graph has v2's content
        RouteGraph retrieved = store.findByContentHash(hashOpt.get()).orElseThrow();
        assertThat(retrieved.getDescription()).isEqualTo("v2");
    }

    @Test
    void findLatestContentHashForAppRoute_returnsLatestAcrossInstances() throws InterruptedException {
        // v1 published by one agent, v2 by a different agent. The app+env+route
        // resolver must pick v2 regardless of which instance produced it, and
        // must keep working even if neither instance is "live" anywhere.
        RouteGraph v1 = buildGraph("evolving-route", "n-a");
        v1.setDescription("v1");
        RouteGraph v2 = buildGraph("evolving-route", "n-a", "n-b");
        v2.setDescription("v2");

        store.store(new TaggedDiagram("publisher-old", "versioned-app", "default", v1));
        Thread.sleep(10);
        store.store(new TaggedDiagram("publisher-new", "versioned-app", "default", v2));

        Optional<String> hashOpt = store.findLatestContentHashForAppRoute(
                "versioned-app", "evolving-route", "default");
        assertThat(hashOpt).isPresent();

        RouteGraph retrieved = store.findByContentHash(hashOpt.get()).orElseThrow();
        assertThat(retrieved.getDescription()).isEqualTo("v2");
    }

    @Test
    void findLatestContentHashForAppRoute_isolatesByAppAndEnv() {
        RouteGraph graph = buildGraph("shared-route", "node-1");
        store.store(new TaggedDiagram("a1", "app-alpha", "dev", graph));
        store.store(new TaggedDiagram("a2", "app-beta", "prod", graph));

        // Same route id exists across two (app, env) combos. The resolver must
        // return empty for a mismatch on either dimension.
        assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "dev"))
                .isPresent();
        assertThat(store.findLatestContentHashForAppRoute("app-alpha", "shared-route", "prod"))
                .isEmpty();
        assertThat(store.findLatestContentHashForAppRoute("app-beta", "shared-route", "dev"))
                .isEmpty();
        assertThat(store.findLatestContentHashForAppRoute("app-gamma", "shared-route", "dev"))
                .isEmpty();
    }

    @Test
    void findLatestContentHashForAppRoute_emptyInputsReturnEmpty() {
        assertThat(store.findLatestContentHashForAppRoute(null, "r", "default")).isEmpty();
        assertThat(store.findLatestContentHashForAppRoute("app", null, "default")).isEmpty();
        assertThat(store.findLatestContentHashForAppRoute("app", "r", null)).isEmpty();
        assertThat(store.findLatestContentHashForAppRoute("", "r", "default")).isEmpty();
    }

    @Test
    void findProcessorRouteMapping_extractsMapping() {
        // Build a graph with 3 nodes: root + 2 children
        RouteGraph graph = buildGraph("route-5", "proc-from-1", "proc-to-2", "proc-log-3");
        store.store(tagged("agent-1", "app-mapping", graph));

        jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");

        Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping", "default");

        assertThat(mapping).containsEntry("proc-from-1", "route-5");
        assertThat(mapping).containsEntry("proc-to-2", "route-5");
        assertThat(mapping).containsEntry("proc-log-3", "route-5");
    }

    @Test
    void findProcessorRouteMapping_multipleRoutes() {
        RouteGraph graphA = buildGraph("route-a", "proc-a1", "proc-a2");
        RouteGraph graphB = buildGraph("route-b", "proc-b1");
        store.store(tagged("agent-1", "multi-app", graphA));
        store.store(tagged("agent-1", "multi-app", graphB));

        jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");

        Map<String, String> mapping = store.findProcessorRouteMapping("multi-app", "default");

        assertThat(mapping).containsEntry("proc-a1", "route-a");
        assertThat(mapping).containsEntry("proc-a2", "route-a");
        assertThat(mapping).containsEntry("proc-b1", "route-b");
    }

    @Test
    void findProcessorRouteMapping_unknownAppReturnsEmpty() {
        Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app", "default");
        assertThat(mapping).isEmpty();
    }
}
