package com.cameleer.server.core.ingestion;

import com.cameleer.common.model.ExecutionChunk;
import com.cameleer.common.model.ExecutionStatus;
import com.cameleer.common.model.FlatProcessorRecord;
import com.cameleer.server.core.storage.DiagramStore;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;

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

class ChunkAccumulatorTest {

    private static final DiagramStore NO_OP_DIAGRAM_STORE = new DiagramStore() {
        public void store(com.cameleer.server.core.ingestion.TaggedDiagram d) {}
        public Optional<com.cameleer.common.graph.RouteGraph> findByContentHash(String h) { return Optional.empty(); }
        public Optional<String> findContentHashForRoute(String r, String a) { return Optional.empty(); }
        public Optional<String> findLatestContentHashForAppRoute(String app, String r, String env) { return Optional.empty(); }
        public Map<String, String> findProcessorRouteMapping(String app, String env) { return Map.of(); }
    };

    private CopyOnWriteArrayList<MergedExecution> executionSink;
    private CopyOnWriteArrayList<ChunkAccumulator.ProcessorBatch> processorSink;
    private ChunkAccumulator accumulator;

    @BeforeEach
    void setUp() {
        executionSink = new CopyOnWriteArrayList<>();
        processorSink = new CopyOnWriteArrayList<>();
        accumulator = new ChunkAccumulator(
                "default", executionSink::add, processorSink::add,
                NO_OP_DIAGRAM_STORE, Duration.ofMinutes(5), id -> "default");
    }

    @Test
    void singleFinalChunk_producesExecutionAndProcessors() {
        ExecutionChunk chunk = chunk("ex-1", "COMPLETED",
                Instant.parse("2026-03-31T10:00:00Z"),
                Instant.parse("2026-03-31T10:00:01Z"), 1000L,
                0, true,
                List.of(proc(1, null, "log1", "log", "COMPLETED", 5L)));
        chunk.setCorrelationId("corr-1");
        chunk.setAttributes(Map.of("orderId", "ORD-1"));
        chunk.setTraceId("trace-1");
        chunk.setSpanId("span-1");

        accumulator.onChunk(chunk);

        // Processor sink should receive 1 batch with 1 record
        assertThat(processorSink).hasSize(1);
        ChunkAccumulator.ProcessorBatch batch = processorSink.get(0);
        assertThat(batch.tenantId()).isEqualTo("default");
        assertThat(batch.executionId()).isEqualTo("ex-1");
        assertThat(batch.routeId()).isEqualTo("route-1");
        assertThat(batch.applicationId()).isEqualTo("order-service");
        assertThat(batch.execStartTime()).isEqualTo(Instant.parse("2026-03-31T10:00:00Z"));
        assertThat(batch.processors()).hasSize(1);

        // Execution sink should receive 1 merged execution
        assertThat(executionSink).hasSize(1);
        MergedExecution exec = executionSink.get(0);
        assertThat(exec.tenantId()).isEqualTo("default");
        assertThat(exec.version()).isEqualTo(1L);
        assertThat(exec.executionId()).isEqualTo("ex-1");
        assertThat(exec.routeId()).isEqualTo("route-1");
        assertThat(exec.status()).isEqualTo("COMPLETED");
        assertThat(exec.durationMs()).isEqualTo(1000L);
        assertThat(exec.traceId()).isEqualTo("trace-1");
        assertThat(exec.spanId()).isEqualTo("span-1");
        assertThat(exec.attributes()).contains("orderId");
    }

    @Test
    void multipleChunks_mergesEnvelope_insertsProcessorsImmediately() {
        ExecutionChunk chunk0 = chunk("ex-2", "RUNNING",
                Instant.parse("2026-03-31T10:00:00Z"),
                null, null,
                0, false,
                List.of(
                        proc(1, null, "log1", "log", "COMPLETED", 5L),
                        proc(2, null, "log2", "log", "COMPLETED", 3L)));
        chunk0.setCorrelationId("ex-2");

        accumulator.onChunk(chunk0);

        // Processors pushed immediately on chunk 0
        assertThat(processorSink).hasSize(1);
        assertThat(processorSink.get(0).processors()).hasSize(2);

        // No execution yet (not final)
        assertThat(executionSink).isEmpty();

        ExecutionChunk chunk1 = chunk("ex-2", "COMPLETED",
                Instant.parse("2026-03-31T10:00:00Z"),
                Instant.parse("2026-03-31T10:00:02Z"), 2000L,
                1, true,
                List.of(proc(3, null, "log3", "log", "COMPLETED", 7L)));
        chunk1.setCorrelationId("ex-2");

        accumulator.onChunk(chunk1);

        // Processors from chunk 1 also pushed
        assertThat(processorSink).hasSize(2);
        assertThat(processorSink.get(1).processors()).hasSize(1);

        // Now execution is emitted
        assertThat(executionSink).hasSize(1);
        MergedExecution exec = executionSink.get(0);
        assertThat(exec.status()).isEqualTo("COMPLETED");
        assertThat(exec.durationMs()).isEqualTo(2000L);
    }

    @Test
    void staleExchange_flushedBySweep() throws Exception {
        ChunkAccumulator staleAccumulator = new ChunkAccumulator(
                "default", executionSink::add, processorSink::add,
                NO_OP_DIAGRAM_STORE, Duration.ofMillis(1), id -> "default");

        ExecutionChunk c = chunk("ex-3", "RUNNING",
                Instant.parse("2026-03-31T10:00:00Z"),
                null, null,
                0, false,
                List.of());
        c.setCorrelationId("ex-3");

        staleAccumulator.onChunk(c);
        assertThat(executionSink).isEmpty();

        Thread.sleep(5);
        staleAccumulator.sweepStale();

        assertThat(executionSink).hasSize(1);
        MergedExecution exec = executionSink.get(0);
        assertThat(exec.status()).isEqualTo("RUNNING");
        assertThat(exec.executionId()).isEqualTo("ex-3");
    }

    @Test
    void finalChunkWithErrors_populatesErrorFields() {
        ExecutionChunk c = chunk("ex-4", "FAILED",
                Instant.parse("2026-03-31T10:00:00Z"),
                Instant.parse("2026-03-31T10:00:01Z"), 1000L,
                0, true,
                List.of());
        c.setCorrelationId("ex-4");
        c.setErrorMessage("NullPointerException");
        c.setErrorStackTrace("at com.foo.Bar.baz(Bar.java:42)");
        c.setErrorType("NullPointerException");
        c.setErrorCategory("RUNTIME");
        c.setRootCauseType("NullPointerException");
        c.setRootCauseMessage("null value at index 0");

        accumulator.onChunk(c);

        assertThat(executionSink).hasSize(1);
        MergedExecution exec = executionSink.get(0);
        assertThat(exec.status()).isEqualTo("FAILED");
        assertThat(exec.errorMessage()).isEqualTo("NullPointerException");
        assertThat(exec.errorStacktrace()).isEqualTo("at com.foo.Bar.baz(Bar.java:42)");
        assertThat(exec.errorType()).isEqualTo("NullPointerException");
        assertThat(exec.errorCategory()).isEqualTo("RUNTIME");
        assertThat(exec.rootCauseType()).isEqualTo("NullPointerException");
        assertThat(exec.rootCauseMessage()).isEqualTo("null value at index 0");
    }

    @Test
    void getPendingCount_tracksBufferedExchanges() {
        ExecutionChunk running1 = chunk("ex-5", "RUNNING",
                Instant.parse("2026-03-31T10:00:00Z"),
                null, null,
                0, false,
                List.of());
        running1.setCorrelationId("ex-5");

        ExecutionChunk running2 = chunk("ex-6", "RUNNING",
                Instant.parse("2026-03-31T10:00:00Z"),
                null, null,
                0, false,
                List.of());
        running2.setCorrelationId("ex-6");
        running2.setRouteId("route-2");

        accumulator.onChunk(running1);
        accumulator.onChunk(running2);
        assertThat(accumulator.getPendingCount()).isEqualTo(2);

        // Send final for ex-5
        ExecutionChunk final5 = chunk("ex-5", "COMPLETED",
                Instant.parse("2026-03-31T10:00:00Z"),
                Instant.parse("2026-03-31T10:00:01Z"), 1000L,
                1, true,
                List.of());
        final5.setCorrelationId("ex-5");

        accumulator.onChunk(final5);
        assertThat(accumulator.getPendingCount()).isEqualTo(1);
    }

    /** Helper to create an ExecutionChunk with common fields. */
    private static ExecutionChunk chunk(String exchangeId, String status, Instant start, Instant end, Long duration,
                                        int chunkSeq, boolean isFinal, List<FlatProcessorRecord> processors) {
        ExecutionChunk c = new ExecutionChunk();
        c.setExchangeId(exchangeId);
        c.setApplicationId(exchangeId.equals("ex-1") ? "order-service" : "app");
        c.setInstanceId("agent-1");
        c.setRouteId("route-1");
        c.setCorrelationId(null);
        c.setStatus(ExecutionStatus.valueOf(status));
        c.setStartTime(start);
        c.setEndTime(end);
        c.setDurationMs(duration);
        c.setEngineLevel("REGULAR");
        c.setChunkSeq(chunkSeq);
        c.setFinal(isFinal);
        c.setProcessors(processors);
        return c;
    }

    /** Helper to create a FlatProcessorRecord with minimal fields. */
    private static FlatProcessorRecord proc(int seq, Integer parentSeq,
                                            String processorId, String processorType,
                                            String status, long durationMs) {
        FlatProcessorRecord p = new FlatProcessorRecord(seq, processorId, processorType);
        p.setParentSeq(parentSeq);
        p.setStatus(ExecutionStatus.valueOf(status));
        p.setStartTime(Instant.parse("2026-03-31T10:00:00.100Z"));
        p.setDurationMs(durationMs);
        return p;
    }
}
