package com.cameleer.server.app.controller;

import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.Map;

import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;

/**
 * Integration tests for the search controller endpoints.
 * Tests all filter types independently and in combination.
 */
class SearchControllerIT extends AbstractPostgresIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private TestSecurityHelper securityHelper;

    private final ObjectMapper objectMapper = new ObjectMapper();

    private static String jwt;
    private static String viewerJwt;
    private static boolean seeded;

    /**
     * Seed test data: Insert executions with varying statuses, times, durations,
     * correlationIds, error messages, and exchange snapshot data.
     */
    @BeforeEach
    void seedTestData() {
        // Lift max_agents cap unconditionally so this IT (which registers an agent on first
        // seed) isn't gated by license enforcement on this run or any sibling that follows.
        // Cap behaviour itself is exercised by AgentCapEnforcementIT.
        securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
        if (seeded) return;
        seeded = true;
        jwt = securityHelper.registerTestAgent("test-agent-search-it");
        viewerJwt = securityHelper.viewerToken();

        // Execution 1: COMPLETED, short duration, no errors
        ingest("""
                {
                  "exchangeId": "ex-search-1",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-1",
                  "correlationId": "corr-alpha",
                  "status": "COMPLETED",
                  "startTime": "2026-03-10T10:00:00Z",
                  "endTime": "2026-03-10T10:00:00.050Z",
                  "durationMs": 50,
                  "chunkSeq": 0,
                  "final": true,
                  "processors": [
                    {
                      "seq": 1,
                      "processorId": "proc-1",
                      "processorType": "log",
                      "status": "COMPLETED",
                      "startTime": "2026-03-10T10:00:00Z",
                      "durationMs": 50,
                      "inputBody": "customer-123 order data",
                      "outputBody": "processed customer-123",
                      "inputHeaders": {"Content-Type": "application/json"},
                      "outputHeaders": {"X-Trace": "abc"}
                    }
                  ]
                }
                """);

        // Execution 2: FAILED with NullPointerException, medium duration
        ingest("""
                {
                  "exchangeId": "ex-search-2",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-2",
                  "correlationId": "corr-beta",
                  "status": "FAILED",
                  "startTime": "2026-03-10T12:00:00Z",
                  "endTime": "2026-03-10T12:00:00.200Z",
                  "durationMs": 200,
                  "errorMessage": "NullPointerException in OrderService",
                  "errorStackTrace": "java.lang.NullPointerException\\n  at com.example.OrderService.process(OrderService.java:42)",
                  "chunkSeq": 0,
                  "final": true,
                  "processors": []
                }
                """);

        // Execution 3: RUNNING, long duration, different time window
        ingest("""
                {
                  "exchangeId": "ex-search-3",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-3",
                  "correlationId": "corr-gamma",
                  "status": "RUNNING",
                  "startTime": "2026-03-11T08:00:00Z",
                  "endTime": "2026-03-11T08:00:01Z",
                  "durationMs": 1000,
                  "chunkSeq": 0,
                  "final": true,
                  "processors": []
                }
                """);

        // Execution 4: FAILED with MyException in stack trace
        ingest("""
                {
                  "exchangeId": "ex-search-4",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-4",
                  "correlationId": "corr-delta",
                  "status": "FAILED",
                  "startTime": "2026-03-10T14:00:00Z",
                  "endTime": "2026-03-10T14:00:00.300Z",
                  "durationMs": 300,
                  "errorMessage": "Processing failed",
                  "errorStackTrace": "com.example.MyException: something broke\\n  at com.example.Handler.handle(Handler.java:10)",
                  "chunkSeq": 0,
                  "final": true,
                  "processors": [
                    {
                      "seq": 1,
                      "processorId": "proc-4",
                      "processorType": "bean",
                      "status": "FAILED",
                      "startTime": "2026-03-10T14:00:00Z",
                      "durationMs": 300,
                      "inputHeaders": {"Content-Type": "text/plain"}
                    }
                  ]
                }
                """);

        // Insert 6 more COMPLETED executions for pagination testing (total = 10)
        for (int i = 5; i <= 10; i++) {
            ingest(String.format("""
                    {
                      "exchangeId": "ex-search-%d",
                      "applicationId": "test-group",
                      "instanceId": "test-agent-search-it",
                      "routeId": "search-route-%d",
                      "correlationId": "corr-page-%d",
                      "status": "COMPLETED",
                      "startTime": "2026-03-10T15:00:%02d.000Z",
                      "endTime": "2026-03-10T15:00:%02d.100Z",
                      "durationMs": 100,
                      "chunkSeq": 0,
                      "final": true,
                      "processors": []
                    }
                    """, i, i, i, i, i));
        }

        // Executions 11-12: carry structured attributes used by the attribute-filter tests.
        ingest("""
                {
                  "exchangeId": "ex-search-attr-1",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-attr-1",
                  "correlationId": "corr-attr-alpha",
                  "status": "COMPLETED",
                  "startTime": "2026-03-12T10:00:00Z",
                  "endTime": "2026-03-12T10:00:00.050Z",
                  "durationMs": 50,
                  "attributes": {"order": "12345", "tenant": "acme"},
                  "chunkSeq": 0,
                  "final": true,
                  "processors": []
                }
                """);
        ingest("""
                {
                  "exchangeId": "ex-search-attr-2",
                  "applicationId": "test-group",
                  "instanceId": "test-agent-search-it",
                  "routeId": "search-route-attr-2",
                  "correlationId": "corr-attr-beta",
                  "status": "COMPLETED",
                  "startTime": "2026-03-12T10:01:00Z",
                  "endTime": "2026-03-12T10:01:00.050Z",
                  "durationMs": 50,
                  "attributes": {"order": "99999"},
                  "chunkSeq": 0,
                  "final": true,
                  "processors": []
                }
                """);

        // Wait for async ingestion + search indexing via REST (no raw SQL).
        // Probe the last seeded execution to avoid false positives from
        // other test classes that may have written into the shared CH tables.
        await().atMost(30, SECONDS).untilAsserted(() -> {
            ResponseEntity<String> r = searchGet("?correlationId=corr-page-10");
            JsonNode body = objectMapper.readTree(r.getBody());
            assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
        });
        await().atMost(30, SECONDS).untilAsserted(() -> {
            ResponseEntity<String> r = searchGet("?correlationId=corr-attr-beta");
            JsonNode body = objectMapper.readTree(r.getBody());
            assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
        });
    }

    @Test
    void searchByStatus_returnsOnlyMatchingExecutions() throws Exception {
        ResponseEntity<String> response = searchGet("?status=FAILED");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(2);
        assertThat(body.get("offset").asInt()).isEqualTo(0);
        assertThat(body.get("limit").asInt()).isEqualTo(50);
        assertThat(body.get("data")).isNotNull();
        body.get("data").forEach(item ->
                assertThat(item.get("status").asText()).isEqualTo("FAILED"));
    }

    @Test
    void searchByTimeRange_returnsOnlyExecutionsInRange() throws Exception {
        ResponseEntity<String> response = searchGet(
                "?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-alpha");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");

        ResponseEntity<String> response2 = searchGet(
                "?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-gamma");
        JsonNode body2 = objectMapper.readTree(response2.getBody());
        assertThat(body2.get("total").asLong()).isZero();
    }

    @Test
    void searchByDuration_returnsOnlyMatchingExecutions() throws Exception {
        ResponseEntity<String> response = searchGet("?correlationId=corr-beta");
        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);

        ResponseEntity<String> response2 = searchPost("""
                {
                  "durationMin": 100,
                  "durationMax": 500,
                  "correlationId": "corr-alpha"
                }
                """);
        JsonNode body2 = objectMapper.readTree(response2.getBody());
        assertThat(body2.get("total").asLong()).isZero();

        ResponseEntity<String> response3 = searchPost("""
                {
                  "durationMin": 100,
                  "durationMax": 500,
                  "correlationId": "corr-delta"
                }
                """);
        JsonNode body3 = objectMapper.readTree(response3.getBody());
        assertThat(body3.get("total").asLong()).isEqualTo(1);
    }

    @Test
    void searchByCorrelationId_returnsOnlyMatchingExecution() throws Exception {
        ResponseEntity<String> response = searchGet("?correlationId=corr-alpha");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
    }

    @Test
    void fullTextSearchGlobal_findsMatchInErrorMessage() throws Exception {
        ResponseEntity<String> response = searchPost("""
                { "text": "NullPointerException" }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
    }

    @Test
    void fullTextSearchGlobal_returnsEmptyForNonexistent() throws Exception {
        ResponseEntity<String> response = searchPost("""
                { "text": "nonexistent-term-xyz-12345" }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isZero();
        assertThat(body.get("data")).isEmpty();
    }

    @Test
    void fullTextSearchInBody_findsMatchInExchangeBody() throws Exception {
        ResponseEntity<String> response = searchPost("""
                { "textInBody": "customer-123" }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-1");
    }

    @Test
    void fullTextSearchInHeaders_findsMatchInExchangeHeaders() throws Exception {
        ResponseEntity<String> response = searchPost("""
                { "textInHeaders": "Content-Type" }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
    }

    @Test
    void fullTextSearchInErrors_findsMatchInStackTrace() throws Exception {
        ResponseEntity<String> response = searchPost("""
                { "textInErrors": "MyException" }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-4");
    }

    @Test
    void combinedFilters_statusAndText() throws Exception {
        ResponseEntity<String> response = searchPost("""
                {
                  "status": "FAILED",
                  "text": "NullPointer"
                }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
    }

    @Test
    void postAdvancedSearch_allFiltersWork() throws Exception {
        ResponseEntity<String> response = searchPost("""
                {
                  "status": "COMPLETED",
                  "timeFrom": "2026-03-10T09:00:00Z",
                  "timeTo": "2026-03-10T11:00:00Z",
                  "durationMin": 0,
                  "durationMax": 100,
                  "correlationId": "corr-alpha"
                }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
    }

    @Test
    void pagination_worksCorrectly() throws Exception {
        ResponseEntity<String> countResponse = searchGet("?status=COMPLETED&limit=1");
        JsonNode countBody = objectMapper.readTree(countResponse.getBody());
        long totalCompleted = countBody.get("total").asLong();
        assertThat(totalCompleted).isGreaterThanOrEqualTo(7);

        ResponseEntity<String> response = searchPost("""
                {
                  "status": "COMPLETED",
                  "offset": 2,
                  "limit": 3
                }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(totalCompleted);
        assertThat(body.get("data").size()).isEqualTo(3);
        assertThat(body.get("offset").asInt()).isEqualTo(2);
        assertThat(body.get("limit").asInt()).isEqualTo(3);
    }

    @Test
    void emptyResults_returnsCorrectEnvelope() throws Exception {
        ResponseEntity<String> response = searchGet("?status=NONEXISTENT_STATUS");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("data")).isEmpty();
        assertThat(body.get("total").asLong()).isZero();
        assertThat(body.get("offset").asInt()).isEqualTo(0);
        assertThat(body.get("limit").asInt()).isEqualTo(50);
    }

    @Test
    void attrParam_exactMatch_filtersToMatchingExecution() throws Exception {
        ResponseEntity<String> response = searchGet("?attr=order:12345&correlationId=corr-attr-alpha");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
    }

    @Test
    void attrParam_keyOnly_matchesAnyExecutionCarryingTheKey() throws Exception {
        ResponseEntity<String> response = searchGet("?attr=tenant&correlationId=corr-attr-alpha");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
    }

    @Test
    void attrParam_multipleValues_produceIntersection() throws Exception {
        // order:99999 AND tenant=* should yield zero — exec-attr-2 has order=99999 but no tenant.
        ResponseEntity<String> response = searchGet("?attr=order:99999&attr=tenant");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isZero();
    }

    @Test
    void attrParam_invalidKey_returns400() throws Exception {
        ResponseEntity<String> response = searchGet("?attr=bad%20key:x");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
    }

    @Test
    void attributeFilters_inPostBody_filtersCorrectly() throws Exception {
        ResponseEntity<String> response = searchPost("""
                {
                  "attributeFilters": [
                    {"key": "order", "value": "12345"}
                  ],
                  "correlationId": "corr-attr-alpha"
                }
                """);
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
    }

    @Test
    void attrParam_wildcardValue_matchesOnPrefix() throws Exception {
        ResponseEntity<String> response = searchGet("?attr=order:1*&correlationId=corr-attr-alpha");
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

        JsonNode body = objectMapper.readTree(response.getBody());
        assertThat(body.get("total").asLong()).isEqualTo(1);
        assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-attr-alpha");
    }

    // --- Helper methods ---

    private void ingest(String json) {
        restTemplate.postForEntity("/api/v1/data/executions",
                new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
    }

    private ResponseEntity<String> searchGet(String queryString) {
        // GET /api/v1/environments/*/executions/** requires VIEWER+ — use the
        // viewer token, not the agent token (agent would get 403 FORBIDDEN).
        HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
        return restTemplate.exchange(
                "/api/v1/environments/default/executions" + queryString,
                HttpMethod.GET,
                new HttpEntity<>(headers),
                String.class);
    }

    private ResponseEntity<String> searchPost(String jsonBody) {
        return restTemplate.exchange(
                "/api/v1/environments/default/executions/search",
                HttpMethod.POST,
                new HttpEntity<>(jsonBody, securityHelper.authHeaders(viewerJwt)),
                String.class);
    }
}
