package com.cameleer.server.app.alerting.eval;

import com.cameleer.server.app.alerting.config.AlertingProperties;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;

import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class ExchangeMatchEvaluatorTest {

    private ClickHouseSearchIndex searchIndex;
    private EnvironmentRepository envRepo;
    private ExchangeMatchEvaluator eval;

    private static final UUID ENV_ID  = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
    private static final UUID RULE_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
    private static final Instant NOW  = Instant.parse("2026-04-19T10:00:00Z");

    @BeforeEach
    void setUp() {
        searchIndex = mock(ClickHouseSearchIndex.class);
        envRepo     = mock(EnvironmentRepository.class);
        AlertingProperties props = new AlertingProperties(
                null, null, null, null, null, null, null, null, null, null, null, null, null, null);
        eval = new ExchangeMatchEvaluator(searchIndex, envRepo, props);

        var env = new Environment(ENV_ID, "prod", "Production", false, true, null, null, "slate", null);
        when(envRepo.findById(ENV_ID)).thenReturn(Optional.of(env));
    }

    private AlertRule ruleWith(AlertCondition condition) {
        return ruleWith(condition, Map.of());
    }

    private AlertRule ruleWith(AlertCondition condition, Map<String, Object> evalState) {
        return ruleWith(condition, evalState, null);
    }

    private AlertRule ruleWith(AlertCondition condition, Map<String, Object> evalState, Instant createdAt) {
        return new AlertRule(RULE_ID, ENV_ID, "test", null,
                AlertSeverity.WARNING, true, condition.kind(), condition,
                60, 0, 0, null, null, List.of(), List.of(),
                null, null, null, evalState, createdAt, null, null, null);
    }

    private ExchangeMatchCondition perExchangeCondition() {
        return new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);
    }

    private ExecutionSummary summary(String id, Instant startTime, String status) {
        return new ExecutionSummary(id, "direct:test", "inst-1", "orders",
                status, startTime, startTime.plusSeconds(1), 100L,
                null, "", null, null, Map.of(), false, false);
    }

    // ── COUNT_IN_WINDOW ───────────────────────────────────────────────────────

    @Test
    void countMode_firesWhenCountExceedsThreshold() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.COUNT_IN_WINDOW, 5, 300);

        when(searchIndex.countExecutionsForAlerting(any())).thenReturn(7L);

        EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
        assertThat(r).isInstanceOf(EvalResult.Firing.class);
        assertThat(((EvalResult.Firing) r).currentValue()).isEqualTo(7.0);
        assertThat(((EvalResult.Firing) r).threshold()).isEqualTo(5.0);
    }

    @Test
    void countMode_clearWhenCountBelowThreshold() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.COUNT_IN_WINDOW, 5, 300);

        when(searchIndex.countExecutionsForAlerting(any())).thenReturn(3L);

        EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
        assertThat(r).isEqualTo(EvalResult.Clear.INSTANCE);
    }

    @Test
    void countMode_passesCorrectSpecToIndex() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", "direct:pay", null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of("orderId", "123")),
                FireMode.COUNT_IN_WINDOW, 1, 120);

        when(searchIndex.countExecutionsForAlerting(any())).thenReturn(2L);

        eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));

        ArgumentCaptor<AlertMatchSpec> captor = ArgumentCaptor.forClass(AlertMatchSpec.class);
        verify(searchIndex).countExecutionsForAlerting(captor.capture());
        AlertMatchSpec spec = captor.getValue();

        assertThat(spec.applicationId()).isEqualTo("orders");
        assertThat(spec.routeId()).isEqualTo("direct:pay");
        assertThat(spec.status()).isEqualTo("FAILED");
        assertThat(spec.attributes()).containsEntry("orderId", "123");
        assertThat(spec.environment()).isEqualTo("prod");
        assertThat(spec.from()).isEqualTo(NOW.minusSeconds(120));
        assertThat(spec.to()).isEqualTo(NOW);
        assertThat(spec.after()).isNull();
    }

    // ── PER_EXCHANGE ──────────────────────────────────────────────────────────

    @Test
    void perExchange_returnsEmptyBatchWhenNoMatches() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);

        when(searchIndex.search(any())).thenReturn(SearchResult.empty(0, 50));

        EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
        assertThat(r).isInstanceOf(EvalResult.Batch.class);
        assertThat(((EvalResult.Batch) r).firings()).isEmpty();
    }

    @Test
    void perExchange_returnsOneFiringPerMatch() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);

        Instant t1 = NOW.minusSeconds(50);
        Instant t2 = NOW.minusSeconds(30);
        Instant t3 = NOW.minusSeconds(10);

        when(searchIndex.search(any())).thenReturn(new SearchResult<>(
                List.of(
                        summary("ex-1", t1, "FAILED"),
                        summary("ex-2", t2, "FAILED"),
                        summary("ex-3", t3, "FAILED")
                ), 3L, 0, 50));

        EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
        assertThat(r).isInstanceOf(EvalResult.Batch.class);
        var batch = (EvalResult.Batch) r;
        assertThat(batch.firings()).hasSize(3);
    }

    @Test
    void perExchange_batchCarriesNextCursorInEvalState() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);

        Instant t1 = NOW.minusSeconds(50);
        Instant t2 = NOW.minusSeconds(10); // latest

        when(searchIndex.search(any())).thenReturn(new SearchResult<>(
                List.of(summary("ex-1", t1, "FAILED"), summary("ex-2", t2, "FAILED")),
                2L, 0, 50));

        EvalResult r = eval.evaluate(condition, ruleWith(condition), new EvalContext("default", NOW, new TickCache()));
        var batch = (EvalResult.Batch) r;

        // The batch carries the composite next-cursor in nextEvalState under "lastExchangeCursor"
        assertThat(batch.nextEvalState()).containsKey("lastExchangeCursor");
        assertThat(batch.nextEvalState().get("lastExchangeCursor"))
                .isEqualTo(t2.toString() + "|ex-2");
    }

    @Test
    void perExchange_usesLastExchangeCursorFromEvalState() {
        var condition = new ExchangeMatchCondition(
                new AlertScope("orders", null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);

        Instant cursor = NOW.minusSeconds(120);
        var rule = ruleWith(condition, Map.of("lastExchangeCursor", cursor.toString() + "|ex-prev"));

        when(searchIndex.search(any())).thenReturn(SearchResult.empty(0, 50));

        eval.evaluate(condition, rule, new EvalContext("default", NOW, new TickCache()));

        // Verify the search request used the cursor tuple: timeFrom + afterExecutionId
        ArgumentCaptor<com.cameleer.server.core.search.SearchRequest> captor =
                ArgumentCaptor.forClass(com.cameleer.server.core.search.SearchRequest.class);
        verify(searchIndex).search(captor.capture());
        assertThat(captor.getValue().timeFrom()).isEqualTo(cursor);
        assertThat(captor.getValue().afterExecutionId()).isEqualTo("ex-prev");
    }

    @Test
    void cursorMonotonicity_sameMillisecondExchanges_fireExactlyOncePerTick() {
        var t = Instant.parse("2026-04-22T10:00:00Z");
        var exA = summary("exec-a", t, "FAILED");
        var exB = summary("exec-b", t, "FAILED");
        when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(exA, exB), 2L, 0, 50));

        ExchangeMatchCondition condition = perExchangeCondition();
        AlertRule rule = ruleWith(condition, Map.of()).withEvalState(Map.of()); // first-run
        EvalResult r1 = eval.evaluate(condition, rule,
                new EvalContext("default", t.plusSeconds(1), new TickCache()));

        assertThat(r1).isInstanceOf(EvalResult.Batch.class);
        var batch1 = (EvalResult.Batch) r1;
        assertThat(batch1.firings()).hasSize(2);
        assertThat(batch1.nextEvalState()).containsKey("lastExchangeCursor");
        // cursor is (t, "exec-b") since "exec-b" > "exec-a" lexicographically

        // Tick 2: reflect the advanced cursor back; expect zero firings
        AlertRule advanced = rule.withEvalState(batch1.nextEvalState());
        when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50));
        EvalResult r2 = eval.evaluate(condition, advanced,
                new EvalContext("default", t.plusSeconds(2), new TickCache()));
        assertThat(((EvalResult.Batch) r2).firings()).isEmpty();

        // Tick 3: a third exchange at the same t with exec-c > exec-b; expect exactly one firing
        var exC = summary("exec-c", t, "FAILED");
        when(searchIndex.search(any())).thenReturn(new SearchResult<>(List.of(exC), 1L, 0, 50));
        EvalResult r3 = eval.evaluate(condition, advanced,
                new EvalContext("default", t.plusSeconds(3), new TickCache()));
        assertThat(((EvalResult.Batch) r3).firings()).hasSize(1);
        assertThat(((EvalResult.Batch) r3).nextEvalState()).containsKey("lastExchangeCursor");
    }

    @Test
    void firstRun_boundedByRuleCreatedAt_notRetentionHistory() {
        var created = Instant.parse("2026-04-22T09:00:00Z");
        var after   = created.plus(Duration.ofMinutes(30));

        // The evaluator must pass `timeFrom = created` to the search.
        ArgumentCaptor<SearchRequest> cap = ArgumentCaptor.forClass(SearchRequest.class);
        when(searchIndex.search(cap.capture())).thenReturn(
                new SearchResult<>(List.of(summary("exec-after", after, "FAILED")), 1L, 0, 50));

        ExchangeMatchCondition condition = perExchangeCondition();
        AlertRule rule = ruleWith(condition, Map.of(), created).withEvalState(Map.of()); // no cursor
        EvalResult r = eval.evaluate(condition, rule,
                new EvalContext("default", after.plusSeconds(10), new TickCache()));

        SearchRequest req = cap.getValue();
        assertThat(req.timeFrom()).isEqualTo(created);
        assertThat(((EvalResult.Batch) r).firings()).hasSize(1);
    }

    @Test
    void firstRun_clampsCursorToDeployBacklogCap_whenRuleCreatedLongAgo() {
        // Rule created 7 days ago, cap default is 24h; expect timeFrom to be now - 24h, not rule.createdAt.
        Instant now = Instant.parse("2026-04-22T12:00:00Z");
        Instant createdLongAgo = now.minus(Duration.ofDays(7));
        Instant expectedClampFloor = now.minusSeconds(86_400); // 24h

        ArgumentCaptor<SearchRequest> cap = ArgumentCaptor.forClass(SearchRequest.class);
        when(searchIndex.search(cap.capture())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50));

        ExchangeMatchCondition condition = perExchangeCondition();
        AlertRule rule = ruleWith(condition, Map.of(), createdLongAgo);
        eval.evaluate(condition, rule, new EvalContext("default", now, new TickCache()));

        SearchRequest req = cap.getValue();
        assertThat(req.timeFrom()).isEqualTo(expectedClampFloor);
    }

    @Test
    void firstRun_usesCreatedAt_whenWithinDeployBacklogCap() {
        Instant now = Instant.parse("2026-04-22T12:00:00Z");
        Instant createdRecent = now.minus(Duration.ofHours(1)); // 1h < 24h cap

        ArgumentCaptor<SearchRequest> cap = ArgumentCaptor.forClass(SearchRequest.class);
        when(searchIndex.search(cap.capture())).thenReturn(new SearchResult<>(List.of(), 0L, 0, 50));

        ExchangeMatchCondition condition = perExchangeCondition();
        AlertRule rule = ruleWith(condition, Map.of(), createdRecent);
        eval.evaluate(condition, rule, new EvalContext("default", now, new TickCache()));

        assertThat(cap.getValue().timeFrom()).isEqualTo(createdRecent);
    }

    @Test
    void kindIsExchangeMatch() {
        assertThat(eval.kind()).isEqualTo(ConditionKind.EXCHANGE_MATCH);
    }
}
