package com.cameleer.server.app.search;

import com.cameleer.common.model.LogEntry;
import com.cameleer.server.core.search.LogSearchRequest;
import com.cameleer.server.core.search.LogSearchResponse;
import com.cameleer.server.core.storage.LogEntryResult;
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.time.Instant;
import java.util.List;
import java.util.Map;

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

@Testcontainers
class ClickHouseLogStoreIT {

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

    private JdbcTemplate jdbc;
    private ClickHouseLogStore 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 logs");

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

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

    private LogEntry entry(Instant ts, String level, String logger, String message,
                           String thread, String stackTrace, Map<String, String> mdc) {
        return new LogEntry(ts, level, logger, message, thread, stackTrace, mdc);
    }

    private LogSearchRequest req(String application) {
        return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc", null);
    }

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

    @Test
    void indexBatch_writesLogs() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        List<LogEntry> entries = List.of(
                entry(now, "INFO", "com.example.Foo", "Hello world", "main", null, null),
                entry(now.plusSeconds(1), "ERROR", "com.example.Bar", "Something failed", "worker-1", "stack...", null)
        );

        store.indexBatch("agent-1", "my-app", entries);

        Long count = jdbc.queryForObject("SELECT count() FROM logs WHERE application = 'my-app'", Long.class);
        assertThat(count).isEqualTo(2);
    }

    @Test
    void search_byApplication_returnsLogs() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "app-a", List.of(
                entry(now, "INFO", "logger", "msg-a", "t1", null, null)
        ));
        store.indexBatch("agent-2", "app-b", List.of(
                entry(now, "INFO", "logger", "msg-b", "t1", null, null)
        ));

        LogSearchResponse result = store.search(req("app-a"));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).message()).isEqualTo("msg-a");
        assertThat(result.data().get(0).application()).isEqualTo("app-a");
        assertThat(result.data().get(0).instanceId()).isEqualTo("agent-1");
    }

    @Test
    void search_byLevel_filtersCorrectly() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "info message", "t1", null, null),
                entry(now.plusSeconds(1), "ERROR", "logger", "error message", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).level()).isEqualTo("ERROR");
        assertThat(result.data().get(0).message()).isEqualTo("error message");
    }

    @Test
    void search_multiLevel_filtersCorrectly() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "info msg", "t1", null, null),
                entry(now.plusSeconds(1), "WARN", "logger", "warn msg", "t1", null, null),
                entry(now.plusSeconds(2), "ERROR", "logger", "error msg", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(2);
    }

    @Test
    void search_byQuery_usesLikeSearch() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "Processing order #12345", "t1", null, null),
                entry(now.plusSeconds(1), "INFO", "logger", "Health check OK", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                "order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).message()).contains("order #12345");
    }

    @Test
    void search_byExchangeId_matchesTopLevelAndMdc() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        Map<String, String> mdc = Map.of("camel.exchangeId", "exchange-abc");

        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "msg with exchange", "t1", null, mdc),
                entry(now.plusSeconds(1), "INFO", "logger", "msg without exchange", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
        assertThat(result.data().get(0).exchangeId()).isEqualTo("exchange-abc");
    }

    @Test
    void search_byTimeRange_filtersCorrectly() {
        Instant t1 = Instant.parse("2026-03-31T10:00:00Z");
        Instant t2 = Instant.parse("2026-03-31T12:00:00Z");
        Instant t3 = Instant.parse("2026-03-31T14:00:00Z");

        store.indexBatch("agent-1", "my-app", List.of(
                entry(t1, "INFO", "logger", "morning", "t1", null, null),
                entry(t2, "INFO", "logger", "noon", "t1", null, null),
                entry(t3, "INFO", "logger", "afternoon", "t1", null, null)
        ));

        Instant from = Instant.parse("2026-03-31T11:00:00Z");
        Instant to = Instant.parse("2026-03-31T13:00:00Z");

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).message()).isEqualTo("noon");
    }

    @Test
    void search_crossApp_returnsAllApps() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "app-a", List.of(
                entry(now, "INFO", "logger", "msg-a", "t1", null, null)
        ));
        store.indexBatch("agent-2", "app-b", List.of(
                entry(now, "INFO", "logger", "msg-b", "t1", null, null)
        ));

        // No application filter — should return both
        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, null, null, null, null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(2);
    }

    @Test
    void search_byLogger_filtersCorrectly() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "com.example.OrderProcessor", "order msg", "t1", null, null),
                entry(now.plusSeconds(1), "INFO", "com.example.PaymentService", "payment msg", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
    }

    @Test
    void search_cursorPagination_works() {
        Instant base = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(base, "INFO", "logger", "msg-1", "t1", null, null),
                entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
                entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null),
                entry(base.plusSeconds(3), "INFO", "logger", "msg-4", "t1", null, null),
                entry(base.plusSeconds(4), "INFO", "logger", "msg-5", "t1", null, null)
        ));

        // Page 1: limit 2
        LogSearchResponse page1 = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc", null));

        assertThat(page1.data()).hasSize(2);
        assertThat(page1.hasMore()).isTrue();
        assertThat(page1.nextCursor()).isNotNull();
        assertThat(page1.data().get(0).message()).isEqualTo("msg-5");

        // Page 2: use cursor
        LogSearchResponse page2 = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc", null));

        assertThat(page2.data()).hasSize(2);
        assertThat(page2.hasMore()).isTrue();
        assertThat(page2.data().get(0).message()).isEqualTo("msg-3");

        // Page 3: last page
        LogSearchResponse page3 = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc", null));

        assertThat(page3.data()).hasSize(1);
        assertThat(page3.hasMore()).isFalse();
        assertThat(page3.nextCursor()).isNull();
    }

    @Test
    void search_levelCounts_correctAndUnaffectedByLevelFilter() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "info1", "t1", null, null),
                entry(now.plusSeconds(1), "INFO", "logger", "info2", "t1", null, null),
                entry(now.plusSeconds(2), "WARN", "logger", "warn1", "t1", null, null),
                entry(now.plusSeconds(3), "ERROR", "logger", "err1", "t1", null, null)
        ));

        // Filter for ERROR only, but counts should include all levels
        LogSearchResponse result = store.search(new LogSearchRequest(
                null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.levelCounts()).containsEntry("INFO", 2L);
        assertThat(result.levelCounts()).containsEntry("WARN", 1L);
        assertThat(result.levelCounts()).containsEntry("ERROR", 1L);
    }

    @Test
    void search_sortAsc_returnsOldestFirst() {
        Instant base = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(base, "INFO", "logger", "msg-1", "t1", null, null),
                entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
                entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null)
        ));

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc", null));

        assertThat(result.data()).hasSize(3);
        assertThat(result.data().get(0).message()).isEqualTo("msg-1");
        assertThat(result.data().get(2).message()).isEqualTo("msg-3");
    }

    @Test
    void search_returnsNewFields() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        Map<String, String> mdc = Map.of("camel.exchangeId", "ex-123", "custom.key", "custom-value");

        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "msg", "t1", null, mdc)
        ));

        LogSearchResponse result = store.search(req("my-app"));

        assertThat(result.data()).hasSize(1);
        LogEntryResult entry = result.data().get(0);
        assertThat(entry.exchangeId()).isEqualTo("ex-123");
        assertThat(entry.instanceId()).isEqualTo("agent-1");
        assertThat(entry.application()).isEqualTo("my-app");
        assertThat(entry.mdc()).containsEntry("custom.key", "custom-value");
    }

    @Test
    void indexBatch_storesMdc() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        Map<String, String> mdc = Map.of(
                "camel.exchangeId", "ex-123",
                "custom.key", "custom-value"
        );

        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "msg", "t1", null, mdc)
        ));

        String exchangeId = jdbc.queryForObject(
                "SELECT exchange_id FROM logs WHERE application = 'my-app' LIMIT 1",
                String.class);
        assertThat(exchangeId).isEqualTo("ex-123");

        String customVal = jdbc.queryForObject(
                "SELECT mdc['custom.key'] FROM logs WHERE application = 'my-app' LIMIT 1",
                String.class);
        assertThat(customVal).isEqualTo("custom-value");
    }

    @Test
    void search_bySources_singleValue_filtersCorrectly() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        // "source" column is populated by indexBatch via LogEntry.getSource(); default is "app" when null.
        // Force one row to "container" via a direct insert to avoid coupling to LogEntry constructor.
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "app msg", "t1", null, null)
        ));
        jdbc.update("INSERT INTO logs (tenant_id, environment, timestamp, application, instance_id, level, " +
                "logger_name, message, thread_name, stack_trace, exchange_id, mdc, source) VALUES " +
                "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                "default", "default", java.sql.Timestamp.from(now.plusSeconds(1)), "my-app", "agent-1",
                "INFO", "logger", "container msg", "t1", "", "", java.util.Map.of(), "container");

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null,
                List.of("container"), null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(1);
        assertThat(result.data().get(0).message()).isEqualTo("container msg");
    }

    @Test
    void search_bySources_multiValue_joinsAsOr() {
        Instant now = Instant.parse("2026-03-31T12:00:00Z");
        store.indexBatch("agent-1", "my-app", List.of(
                entry(now, "INFO", "logger", "app msg", "t1", null, null)
        ));
        jdbc.update("INSERT INTO logs (tenant_id, environment, timestamp, application, instance_id, level, " +
                "logger_name, message, thread_name, stack_trace, exchange_id, mdc, source) VALUES " +
                "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                "default", "default", java.sql.Timestamp.from(now.plusSeconds(1)), "my-app", "agent-1",
                "INFO", "logger", "container msg", "t1", "", "", java.util.Map.of(), "container");
        jdbc.update("INSERT INTO logs (tenant_id, environment, timestamp, application, instance_id, level, " +
                "logger_name, message, thread_name, stack_trace, exchange_id, mdc, source) VALUES " +
                "(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                "default", "default", java.sql.Timestamp.from(now.plusSeconds(2)), "my-app", "agent-1",
                "INFO", "logger", "agent msg", "t1", "", "", java.util.Map.of(), "agent");

        LogSearchResponse result = store.search(new LogSearchRequest(
                null, null, "my-app", null, null, null, null,
                List.of("app", "container"), null, null, null, 100, "desc", null));

        assertThat(result.data()).hasSize(2);
        assertThat(result.data()).extracting(LogEntryResult::message)
                .containsExactlyInAnyOrder("app msg", "container msg");
    }

    @Test
    void search_cursorPagination_sameMillisecond_doesNotSkip() {
        Instant ts = Instant.parse("2026-04-17T10:00:00Z");
        // Insert 5 rows at the exact same timestamp
        java.util.List<LogEntry> batch = new java.util.ArrayList<>();
        for (int i = 0; i < 5; i++) {
            batch.add(entry(ts, "INFO", "logger", "msg-" + i, "t1", null, null));
        }
        store.indexBatch("agent-1", "my-app", batch);

        // Page through with limit 2; across 3 pages we must see all 5 distinct messages, no duplicates
        java.util.Set<String> seen = new java.util.HashSet<>();
        String cursor = null;
        for (int page = 0; page < 10; page++) {
            LogSearchResponse resp = store.search(new LogSearchRequest(
                    null, null, "my-app", null, null, null, null, null,
                    null, null, cursor, 2, "desc", null));
            for (LogEntryResult r : resp.data()) {
                assertThat(seen.add(r.message())).as("duplicate row returned: " + r.message()).isTrue();
            }
            cursor = resp.nextCursor();
            if (!resp.hasMore()) break;
        }
        assertThat(seen).containsExactlyInAnyOrder("msg-0", "msg-1", "msg-2", "msg-3", "msg-4");
    }
}
