package com.cameleer.server.app.alerting;

import com.cameleer.common.model.LogEntry;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.alerting.eval.AlertEvaluatorJob;
import com.cameleer.server.app.alerting.notify.NotificationDispatchJob;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;

/**
 * Canary integration test — exercises the full alerting lifecycle end-to-end:
 * fire → notify → ack → silence → re-fire (suppressed) → resolve → rule delete.
 * Also verifies the re-notification cadence (reNotifyMinutes).
 *
 * Rule creation is driven through the REST API (POST /alerts/rules), not raw SQL,
 * so target persistence via saveTargets() is exercised on the critical path.
 *
 * Uses real Postgres (Testcontainers) and real ClickHouse for log seeding.
 * WireMock provides the webhook target.
 * Clock is replaced with a @MockBean so the re-notify test can advance time.
 */
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(Lifecycle.PER_CLASS)
class AlertingFullLifecycleIT extends AbstractPostgresIT {

    // Replace the alertingClock bean so we can control time in re-notify test
    @MockBean(name = "alertingClock") Clock alertingClock;

    // ── Spring beans ──────────────────────────────────────────────────────────

    @Autowired private AlertEvaluatorJob evaluatorJob;
    @Autowired private NotificationDispatchJob dispatchJob;
    @Autowired private AlertRuleRepository ruleRepo;
    @Autowired private AlertInstanceRepository instanceRepo;
    @Autowired private AlertNotificationRepository notificationRepo;
    @Autowired private AlertSilenceRepository silenceRepo;
    @Autowired private OutboundConnectionRepository outboundRepo;
    @Autowired private ClickHouseLogStore logStore;
    @Autowired private ClickHouseExecutionStore executionStore;
    @Autowired private SecretCipher secretCipher;
    @Autowired private TestRestTemplate restTemplate;
    @Autowired private TestSecurityHelper securityHelper;
    @Autowired private ObjectMapper objectMapper;

    @Value("${cameleer.server.tenant.id:default}")
    private String tenantId;

    // ── Test state shared across @Test methods ─────────────────────────────────

    private WireMockServer wm;

    private String operatorJwt;
    private String envSlug;
    private UUID envId;
    private UUID ruleId;
    private UUID connId;
    private UUID instanceId;   // filled after first FIRING

    // Current simulated clock time — starts at "now" and can be advanced
    private Instant simulatedNow = Instant.now();

    // ── Setup / teardown ──────────────────────────────────────────────────────

    /**
     * Mockito resets @MockBean stubs between @Test methods even with PER_CLASS lifecycle.
     * Re-stub the clock before every test so clock.instant() never returns null.
     */
    @BeforeEach
    void refreshClock() {
        stubClock();
    }

    @BeforeAll
    void seedFixtures() throws Exception {
        wm = new WireMockServer(WireMockConfiguration.options()
                .httpDisabled(true)
                .dynamicHttpsPort());
        wm.start();

        // Default clock behaviour: delegate to simulatedNow
        stubClock();

        operatorJwt = securityHelper.operatorToken();

        // Seed operator user in Postgres
        jdbcTemplate.update(
            "INSERT INTO users (user_id, provider, email, display_name) VALUES ('test-operator', 'test', 'op@lc.test', 'Op') ON CONFLICT (user_id) DO NOTHING");

        // Seed environment
        envSlug = "lc-env-" + UUID.randomUUID().toString().substring(0, 6);
        envId   = UUID.randomUUID();
        jdbcTemplate.update(
            "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
            envId, envSlug, "LC Env");

        // Seed outbound connection (WireMock HTTPS, TRUST_ALL, with HMAC secret)
        connId = UUID.randomUUID();
        String hmacCiphertext = secretCipher.encrypt("test-hmac-secret");
        String webhookUrl = "https://localhost:" + wm.httpsPort() + "/webhook";
        jdbcTemplate.update(
            "INSERT INTO outbound_connections" +
            "  (id, tenant_id, name, url, method, tls_trust_mode, tls_ca_pem_paths," +
            "   hmac_secret_ciphertext, auth_kind, auth_config, default_headers," +
            "   allowed_environment_ids, created_by, updated_by)" +
            " VALUES (?, ?, 'lc-webhook', ?," +
            "         'POST'::outbound_method_enum," +
            "         'TRUST_ALL'::trust_mode_enum," +
            "         '[]'::jsonb," +
            "         ?, 'NONE'::outbound_auth_kind_enum, '{}'::jsonb, '{}'::jsonb," +
            "         '{}'," +
            "         'test-operator', 'test-operator')",
            connId, tenantId, webhookUrl, hmacCiphertext);

        // Create alert rule via REST API (exercises saveTargets on the write path)
        ruleId = createRuleViaRestApi();
    }

    @AfterAll
    void cleanupFixtures() {
        if (wm != null) wm.stop();
        jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
        jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM alert_rule_targets WHERE rule_id IN (SELECT id FROM alert_rules WHERE environment_id = ?)", envId);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId);
        jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
        jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-operator'");
    }

    // ── Test methods (ordered) ────────────────────────────────────────────────

    @Test
    @Order(1)
    void step1_seedLogAndEvaluate_createsFireInstance() throws Exception {
        // Stub WireMock to return 200
        wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(200).withBody("accepted")));

        // Seed a matching log into ClickHouse BEFORE capturing simulatedNow,
        // so the log timestamp is guaranteed to fall inside [simulatedNow-300s, simulatedNow].
        seedMatchingLog();

        // Set simulatedNow to current wall time — the log was inserted a few ms earlier,
        // so its timestamp is guaranteed <= simulatedNow within the 300s window.
        setSimulatedNow(Instant.now());

        // Release any claim the background scheduler may have already placed on the rule,
        // and backdate next_evaluation_at so it's due again for our manual tick.
        jdbcTemplate.update(
            "UPDATE alert_rules SET claimed_by = NULL, claimed_until = NULL, " +
            "next_evaluation_at = now() - interval '1 second' WHERE id = ?", ruleId);

        // Verify rule is in DB and due (no claim outstanding)
        Integer ruleCount = jdbcTemplate.queryForObject(
            "SELECT count(*) FROM alert_rules WHERE id = ? AND enabled = true " +
            "AND next_evaluation_at <= now() AND (claimed_until IS NULL OR claimed_until < now())",
            Integer.class, ruleId);
        assertThat(ruleCount).as("rule must be unclaimed and due before tick").isEqualTo(1);

        // Tick evaluator
        evaluatorJob.tick();

        // Assert FIRING instance created
        List<AlertInstance> instances = instanceRepo.listForInbox(
            envId, List.of(), "test-operator", List.of("OPERATOR"), 10);
        assertThat(instances).hasSize(1);
        assertThat(instances.get(0).state()).isEqualTo(AlertState.FIRING);
        assertThat(instances.get(0).ruleId()).isEqualTo(ruleId);

        // B-1 fix verification: targets were persisted via the REST API path,
        // so target_user_ids must be non-empty (not {} as before the fix)
        assertThat(instances.get(0).targetUserIds())
                .as("target_user_ids must be non-empty — verifies B-1 fix (saveTargets)")
                .isNotEmpty();

        instanceId = instances.get(0).id();
    }

    @Test
    @Order(2)
    void step2_dispatchJob_deliversWebhook() throws Exception {
        assertThat(instanceId).isNotNull();

        // Tick dispatcher
        dispatchJob.tick();

        // Assert DELIVERED notification
        List<AlertNotification> notifs = notificationRepo.listForInstance(instanceId);
        assertThat(notifs).hasSize(1);
        assertThat(notifs.get(0).status()).isEqualTo(NotificationStatus.DELIVERED);
        assertThat(notifs.get(0).lastResponseStatus()).isEqualTo(200);

        // WireMock received exactly one POST with HMAC header
        wm.verify(1, postRequestedFor(urlEqualTo("/webhook"))
                .withHeader("X-Cameleer-Signature", matching("sha256=[0-9a-f]{64}")));

        // Body should contain rule name
        wm.verify(postRequestedFor(urlEqualTo("/webhook"))
                .withRequestBody(containing("lc-timeout-rule")));

        // B-2: lastNotifiedAt must be set after dispatch (step sets it on DELIVERED)
        AlertInstance inst = instanceRepo.findById(instanceId).orElseThrow();
        assertThat(inst.lastNotifiedAt())
                .as("lastNotifiedAt must be set after DELIVERED — verifies B-2 tracking fix")
                .isNotNull();
    }

    @Test
    @Order(3)
    void step3_ack_transitionsToAcknowledged() throws Exception {
        assertThat(instanceId).isNotNull();

        ResponseEntity<String> resp = restTemplate.exchange(
            "/api/v1/environments/" + envSlug + "/alerts/" + instanceId + "/ack",
            HttpMethod.POST,
            new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
            String.class);

        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
        JsonNode body = objectMapper.readTree(resp.getBody());
        assertThat(body.path("state").asText()).isEqualTo("FIRING");

        // DB state
        AlertInstance updated = instanceRepo.findById(instanceId).orElseThrow();
        assertThat(updated.state()).isEqualTo(AlertState.FIRING);
    }

    @Test
    @Order(4)
    void step4_silence_suppressesSubsequentNotification() throws Exception {
        // Create a silence matching this rule
        String silenceBody = objectMapper.writeValueAsString(Map.of(
            "matcher", Map.of("ruleId", ruleId.toString()),
            "reason", "lifecycle-test-silence",
            "startsAt", simulatedNow.minusSeconds(10).toString(),
            "endsAt", simulatedNow.plusSeconds(3600).toString()
        ));
        ResponseEntity<String> silenceResp = restTemplate.exchange(
            "/api/v1/environments/" + envSlug + "/alerts/silences",
            HttpMethod.POST,
            new HttpEntity<>(silenceBody, securityHelper.authHeaders(operatorJwt)),
            String.class);
        assertThat(silenceResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        // Reset WireMock counter
        wm.resetRequests();

        // Inject a fresh PENDING notification for the existing instance — simulates a re-notification
        // attempt that the dispatcher should silently suppress.
        UUID newNotifId = UUID.randomUUID();
        // Look up the webhook_id from the existing notification for this instance
        UUID existingWebhookId = jdbcTemplate.queryForObject(
            "SELECT webhook_id FROM alert_notifications WHERE alert_instance_id = ? LIMIT 1",
            UUID.class, instanceId);
        jdbcTemplate.update(
            "INSERT INTO alert_notifications" +
            "  (id, alert_instance_id, outbound_connection_id, webhook_id," +
            "   status, attempts, next_attempt_at, payload, created_at)" +
            " VALUES (?, ?, ?, ?," +
            "         'PENDING'::notification_status_enum, 0, now() - interval '1 second'," +
            "         '{}'::jsonb, now())",
            newNotifId, instanceId, connId, existingWebhookId);

        // Tick dispatcher — the silence should suppress the notification
        dispatchJob.tick();

        // The injected notification should now be FAILED with snippet "silenced"
        List<AlertNotification> notifs = notificationRepo.listForInstance(instanceId);
        boolean foundSilenced = notifs.stream()
            .anyMatch(n -> NotificationStatus.FAILED.equals(n.status())
                && n.lastResponseSnippet() != null
                && n.lastResponseSnippet().contains("silenced"));
        assertThat(foundSilenced).as("At least one notification should be silenced").isTrue();

        // WireMock should NOT have received a new POST
        wm.verify(0, postRequestedFor(urlEqualTo("/webhook")));
    }

    @Test
    @Order(5)
    void step5_deleteRule_nullifiesRuleIdButPreservesSnapshot() throws Exception {
        // Delete the rule via DELETE endpoint
        ResponseEntity<String> deleteResp = restTemplate.exchange(
            "/api/v1/environments/" + envSlug + "/alerts/rules/" + ruleId,
            HttpMethod.DELETE,
            new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
            String.class);
        assertThat(deleteResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);

        // Rule should be gone from DB
        assertThat(ruleRepo.findById(ruleId)).isEmpty();

        // Existing alert instances should have rule_id = NULL but rule_snapshot still contains name
        List<AlertInstance> remaining = instanceRepo.listForInbox(
            envId, List.of(), "test-operator", List.of("OPERATOR"), 10);
        assertThat(remaining).isNotEmpty();
        for (AlertInstance inst : remaining) {
            // rule_id should now be null (FK ON DELETE SET NULL)
            assertThat(inst.ruleId()).isNull();
            // rule_snapshot should still contain the rule name
            assertThat(inst.ruleSnapshot()).containsKey("name");
            assertThat(inst.ruleSnapshot().get("name").toString()).contains("lc-timeout-rule");
        }
    }

    @Test
    @Order(6)
    void step6_reNotifyCadenceFiresSecondNotification() throws Exception {
        // Standalone sub-test: create a fresh rule with reNotifyMinutes=1 and verify
        // that the evaluator's re-notify sweep enqueues a second notification after 61 seconds.

        wm.resetRequests();
        wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(200).withBody("accepted")));

        // Create a new rule via REST with reNotifyMinutes=1, forDurationSeconds=0
        UUID reNotifyRuleId = createReNotifyRuleViaRestApi();

        // Seed the log BEFORE capturing T+0 so the log timestamp falls inside
        // the evaluator window [t0-300s, t0].
        seedMatchingLog();

        // Set T+0 to current wall time — the log was inserted a few ms earlier,
        // so its timestamp is guaranteed <= t0 within the 300s window.
        Instant t0 = Instant.now();
        setSimulatedNow(t0);

        // Tick evaluator at T+0 → instance FIRING, notification PENDING
        evaluatorJob.tick();

        List<AlertInstance> instances = instanceRepo.listForInbox(
            envId, List.of(), "test-operator", List.of("OPERATOR"), 10);
        // Find the instance for the reNotify rule
        AlertInstance inst = instances.stream()
                .filter(i -> reNotifyRuleId.equals(i.ruleId()))
                .findFirst()
                .orElse(null);
        assertThat(inst).as("FIRING instance for reNotify rule").isNotNull();
        UUID reNotifyInstanceId = inst.id();

        // Tick dispatcher at T+0 → notification DELIVERED, WireMock: 1 POST
        dispatchJob.tick();
        wm.verify(1, postRequestedFor(urlEqualTo("/webhook")));

        // Verify lastNotifiedAt was stamped (B-2 tracking)
        AlertInstance afterFirstDispatch = instanceRepo.findById(reNotifyInstanceId).orElseThrow();
        assertThat(afterFirstDispatch.lastNotifiedAt()).isNotNull();

        // --- Advance clock 61 seconds ---
        setSimulatedNow(t0.plusSeconds(61));

        // Backdate next_evaluation_at so the rule is claimed again
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
            "claimed_by = NULL, claimed_until = NULL WHERE id = ?", reNotifyRuleId);

        // Tick evaluator at T+61 — re-notify sweep fires because lastNotifiedAt + 1 min <= now
        evaluatorJob.tick();

        // The sweep saves notifications with nextAttemptAt = simulatedNow (T+61s) which is in the
        // future relative to Postgres real clock. Backdate so the dispatcher can claim them.
        jdbcTemplate.update(
            "UPDATE alert_notifications SET next_attempt_at = now() - interval '1 second' " +
            "WHERE alert_instance_id = ? AND status = 'PENDING'::notification_status_enum",
            reNotifyInstanceId);

        // Tick dispatcher → second POST
        dispatchJob.tick();
        wm.verify(2, postRequestedFor(urlEqualTo("/webhook")));

        // Cleanup
        jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id = ?", reNotifyInstanceId);
        jdbcTemplate.update("DELETE FROM alert_instances WHERE id = ?", reNotifyInstanceId);
        jdbcTemplate.update("DELETE FROM alert_rule_targets WHERE rule_id = ?", reNotifyRuleId);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", reNotifyRuleId);
    }

    /**
     * Exactly-once-per-exchange end-to-end lifecycle.
     * <p>
     * 5 FAILED exchanges across 2 evaluator ticks must produce exactly
     * 5 FIRING instances + 5 PENDING notifications (one per exchange, one webhook).
     * A third tick with no new exchanges must be a no-op. Acking one instance
     * must leave the other four untouched.
     * <p>
     * Exercises the full Phase-1+2+3 stack: evaluator cursor persistence across
     * ticks, per-tick rollback isolation, and the ack-doesn't-cascade invariant.
     * See: docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md
     */
    @Test
    @Order(7)
    void perExchange_5FailuresAcross2Ticks_exactlyOncePerExchange() {
        // Relative-to-now timestamps so they fall inside the evaluator's
        // [rule.createdAt .. ctx.now()] window. Using Instant.parse(...) would
        // require reconciling with the mocked alertingClock AND rule.createdAt,
        // which is wall-clock in createPerExchangeRuleWithWebhook.
        Instant base = Instant.now().minusSeconds(30);

        // Pin the mocked alertingClock to current wall time so ctx.now() is >
        // every seeded execution timestamp (base + 0..4s) AND > rule.createdAt
        // (now - 60s). Prior tests may have set simulatedNow far in the past
        // (step1 used wall time but step6 advanced by 61s — test ordering means
        // the last value lingers). Re-pinning here makes the window deterministic.
        setSimulatedNow(Instant.now());

        UUID perExRuleId = createPerExchangeRuleWithWebhook();

        // ── Tick 1 — seed 3, tick ────────────────────────────────────────────
        seedFailedExecution("ex1-exec-1", base);
        seedFailedExecution("ex1-exec-2", base.plusSeconds(1));
        seedFailedExecution("ex1-exec-3", base.plusSeconds(2));
        evaluatorJob.tick();

        // ── Tick 2 — seed 2 more, tick ───────────────────────────────────────
        seedFailedExecution("ex1-exec-4", base.plusSeconds(3));
        seedFailedExecution("ex1-exec-5", base.plusSeconds(4));
        // Re-open the rule claim so it's due for tick 2.
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
            "claimed_by = NULL, claimed_until = NULL WHERE id = ?", perExRuleId);
        evaluatorJob.tick();

        // Assert: 5 instances, 5 PENDING notifications.
        List<UUID> instanceIds = instanceIdsForRule(perExRuleId);
        assertThat(instanceIds)
                .as("5 FAILED exchanges across 2 ticks must produce exactly 5 FIRING instances")
                .hasSize(5);
        List<AlertNotification> allNotifs = notificationsForRule(perExRuleId);
        assertThat(allNotifs)
                .as("5 instances × 1 webhook must produce exactly 5 notifications")
                .hasSize(5);
        assertThat(allNotifs.stream().allMatch(n -> n.status() == NotificationStatus.PENDING))
                .as("all notifications must be PENDING before dispatch")
                .isTrue();

        // ── Dispatch all pending, then tick 3 — expect no change ────────────
        dispatchAllPending();
        // Re-open the rule claim so it's due for tick 3.
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
            "claimed_by = NULL, claimed_until = NULL WHERE id = ?", perExRuleId);
        evaluatorJob.tick();

        assertThat(instanceIdsForRule(perExRuleId))
                .as("tick 3 with no new exchanges must not create new instances")
                .hasSize(5);
        long pending = notificationsForRule(perExRuleId).stream()
                .filter(n -> n.status() == NotificationStatus.PENDING)
                .count();
        assertThat(pending)
                .as("tick 3 must not re-enqueue notifications — all prior were dispatched")
                .isZero();

        // ── Ack one — others unchanged ──────────────────────────────────────
        UUID firstInstanceId = instanceIds.get(0);
        instanceRepo.ack(firstInstanceId, "test-operator", Instant.now());

        List<AlertInstance> all = instanceIds.stream()
                .map(id -> instanceRepo.findById(id).orElseThrow())
                .toList();
        long ackedCount = all.stream().filter(i -> i.ackedBy() != null).count();
        assertThat(ackedCount)
                .as("ack on one instance must not cascade to peers")
                .isEqualTo(1);

        // Cleanup — the @AfterAll cleans by envId which covers us, but be explicit.
        jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN " +
            "(SELECT id FROM alert_instances WHERE rule_id = ?)", perExRuleId);
        jdbcTemplate.update("DELETE FROM alert_instances WHERE rule_id = ?", perExRuleId);
        jdbcTemplate.update("DELETE FROM alert_rule_targets WHERE rule_id = ?", perExRuleId);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", perExRuleId);
    }

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

    /** POST the main lifecycle rule via REST API. Returns the created rule ID. */
    private UUID createRuleViaRestApi() throws Exception {
        // Build JSON directly — Map.of() supports at most 10 entries
        String ruleBody = """
            {
              "name": "lc-timeout-rule",
              "severity": "WARNING",
              "conditionKind": "LOG_PATTERN",
              "condition": {
                "kind": "LOG_PATTERN",
                "scope": {"appSlug": "lc-app"},
                "level": "ERROR",
                "pattern": "TimeoutException",
                "threshold": 0,
                "windowSeconds": 300
              },
              "evaluationIntervalSeconds": 60,
              "forDurationSeconds": 0,
              "reNotifyMinutes": 0,
              "notificationTitleTmpl": "Alert: {{rule.name}}",
              "notificationMessageTmpl": "Instance {{alert.id}} fired",
              "webhooks": [{"outboundConnectionId": "%s"}],
              "targets": [{"kind": "USER", "targetId": "test-operator"}]
            }
            """.formatted(connId);

        ResponseEntity<String> resp = restTemplate.exchange(
            "/api/v1/environments/" + envSlug + "/alerts/rules",
            HttpMethod.POST,
            new HttpEntity<>(ruleBody, securityHelper.authHeaders(operatorJwt)),
            String.class);

        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        JsonNode body = objectMapper.readTree(resp.getBody());
        String id = body.path("id").asText();
        assertThat(id).isNotBlank();

        // Backdate next_evaluation_at so it's due immediately
        UUID ruleUuid = UUID.fromString(id);
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second' WHERE id = ?",
            ruleUuid);

        return ruleUuid;
    }

    /** POST a short-cadence re-notify rule via REST API. Returns the created rule ID. */
    private UUID createReNotifyRuleViaRestApi() throws Exception {
        String ruleBody = """
            {
              "name": "lc-renotify-rule",
              "severity": "WARNING",
              "conditionKind": "LOG_PATTERN",
              "condition": {
                "kind": "LOG_PATTERN",
                "scope": {"appSlug": "lc-app"},
                "level": "ERROR",
                "pattern": "TimeoutException",
                "threshold": 0,
                "windowSeconds": 300
              },
              "evaluationIntervalSeconds": 60,
              "forDurationSeconds": 0,
              "reNotifyMinutes": 1,
              "notificationTitleTmpl": "ReNotify: {{rule.name}}",
              "notificationMessageTmpl": "Re-fired {{alert.id}}",
              "webhooks": [{"outboundConnectionId": "%s"}],
              "targets": [{"kind": "USER", "targetId": "test-operator"}]
            }
            """.formatted(connId);

        ResponseEntity<String> resp = restTemplate.exchange(
            "/api/v1/environments/" + envSlug + "/alerts/rules",
            HttpMethod.POST,
            new HttpEntity<>(ruleBody, securityHelper.authHeaders(operatorJwt)),
            String.class);

        assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        JsonNode body = objectMapper.readTree(resp.getBody());
        String id = body.path("id").asText();
        assertThat(id).isNotBlank();

        UUID ruleUuid = UUID.fromString(id);
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second' WHERE id = ?",
            ruleUuid);
        return ruleUuid;
    }

    private void setSimulatedNow(Instant instant) {
        simulatedNow = instant;
        stubClock();
    }

    private void stubClock() {
        Mockito.when(alertingClock.instant()).thenReturn(simulatedNow);
        Mockito.when(alertingClock.getZone()).thenReturn(ZoneOffset.UTC);
    }

    private void seedMatchingLog() {
        LogEntry entry = new LogEntry(
            Instant.now(),
            "ERROR",
            "com.example.OrderService",
            "java.net.SocketTimeoutException: TimeoutException after 5000ms",
            "main",
            null,
            Map.of()
        );
        logStore.insertBufferedBatch(List.of(
            new BufferedLogEntry(tenantId, envSlug, "lc-agent-01", "lc-app", entry)));
    }

    // ── Helpers for perExchange exactly-once test ────────────────────────────

    private static final String PER_EX_APP_SLUG = "per-ex-app";

    /**
     * Create a PER_EXCHANGE rule bound to {@link #PER_EX_APP_SLUG} that fires on
     * {@code status=FAILED} and enqueues one notification per match via the
     * pre-seeded webhook connection ({@link #connId}). Returns the new rule id.
     * <p>
     * Replicates the pattern from {@code AlertEvaluatorJobIT#createPerExchangeRuleWithWebhook}
     * but reuses this test's env + outbound connection.
     */
    private UUID createPerExchangeRuleWithWebhook() {
        UUID rid = UUID.randomUUID();
        Instant now = Instant.now();
        var condition = new ExchangeMatchCondition(
                new AlertScope(PER_EX_APP_SLUG, null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);
        var webhook = new WebhookBinding(connId, null, null, Map.of());
        var rule = new AlertRule(
                rid, envId, "per-ex-lc-rule-" + rid, null,
                AlertSeverity.WARNING, true, ConditionKind.EXCHANGE_MATCH, condition,
                60, 0, 60,
                "Exchange FAILED: {{exchange.id}}", "route={{exchange.routeId}}",
                List.of(webhook), List.of(),
                now.minusSeconds(5),               // due now
                null, null, Map.of(),
                now.minusSeconds(60), "test-operator",  // createdAt bounds first-run cursor
                now.minusSeconds(60), "test-operator");
        ruleRepo.save(rule);
        return rid;
    }

    /**
     * Seed one FAILED execution into ClickHouse, scoped to this test's tenant/env/app
     * so it's picked up by a PER_EXCHANGE rule targeting {@link #PER_EX_APP_SLUG}.
     */
    private void seedFailedExecution(String executionId, Instant startTime) {
        executionStore.insertExecutionBatch(List.of(new MergedExecution(
                tenantId, 1L, executionId, "route-a", "inst-1", PER_EX_APP_SLUG, envSlug,
                "FAILED", "", "exchange-" + executionId,
                startTime, startTime.plusMillis(100), 100L,
                "", "", "", "", "", "",     // error fields
                "", "FULL",                 // diagramContentHash, engineLevel
                "", "", "", "", "", "",     // bodies / headers / properties
                "{}",                        // attributes (JSON)
                "", "",                     // traceId, spanId
                false, false,
                null, null
        )));
    }

    /** All instance ids for a rule, ordered by fired_at ascending (deterministic). */
    private List<UUID> instanceIdsForRule(UUID rid) {
        return jdbcTemplate.queryForList(
                "SELECT id FROM alert_instances WHERE rule_id = ? ORDER BY fired_at ASC",
                UUID.class, rid);
    }

    /** All notifications across every instance of a rule. */
    private List<AlertNotification> notificationsForRule(UUID rid) {
        List<UUID> ids = instanceIdsForRule(rid);
        List<AlertNotification> out = new java.util.ArrayList<>();
        for (UUID iid : ids) {
            out.addAll(notificationRepo.listForInstance(iid));
        }
        return out;
    }

    /**
     * Simulate a dispatch pass without hitting the real webhook — marks every
     * PENDING notification for this rule as DELIVERED. Using
     * {@code dispatchJob.tick()} would round-trip through WireMock and require
     * extra plumbing; the exactly-once contract under test is about the
     * evaluator re-enqueueing behaviour, not webhook delivery.
     */
    private void dispatchAllPending() {
        Instant now = Instant.now();
        // Drain PENDING notifications across the whole env (safe because the
        // ackedBy-scoped assertions further down look at this rule only).
        List<UUID> pendingIds = jdbcTemplate.queryForList(
                "SELECT n.id FROM alert_notifications n " +
                "JOIN alert_instances i ON n.alert_instance_id = i.id " +
                "WHERE i.environment_id = ? " +
                "AND n.status = 'PENDING'::notification_status_enum",
                UUID.class, envId);
        for (UUID nid : pendingIds) {
            notificationRepo.markDelivered(nid, 200, "OK", now);
        }
    }
}
