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

import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.ingestion.MergedExecution;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

/**
 * Integration test for {@link AlertEvaluatorJob}.
 * <p>
 * Uses real Postgres (Testcontainers) for the full claim→persist pipeline.
 * {@code ClickHouseLogStore} is mocked so {@code LogPatternEvaluator} wires up
 * without requiring real ClickHouse query behaviour.
 * {@code AgentRegistryService} is mocked so tests can control which agents
 * are DEAD without depending on in-memory timing.
 */
@Import(AlertEvaluatorJobIT.FaultInjectingNotificationRepoConfig.class)
class AlertEvaluatorJobIT extends AbstractPostgresIT {

    @MockBean(name = "clickHouseLogStore") ClickHouseLogStore    clickHouseLogStore;
    @MockBean AgentRegistryService agentRegistryService;

    @Autowired private AlertEvaluatorJob job;
    @Autowired private AlertRuleRepository ruleRepo;
    @Autowired private AlertInstanceRepository instanceRepo;
    @Autowired private AlertNotificationRepository notificationRepo;
    @Autowired private FaultInjectingNotificationRepository faultInjectingNotificationRepo;
    @Autowired private ClickHouseExecutionStore executionStore;
    @Autowired @Qualifier("clickHouseJdbcTemplate") private JdbcTemplate clickHouseJdbc;

    private UUID envId;
    private UUID ruleId;
    private static final String SYS_USER    = "sys-eval-it";
    private static final String APP_SLUG    = "orders";
    private static final String AGENT_ID    = "test-agent-01";
    private String envSlug;

    @BeforeEach
    void setup() {
        // ClickHouse — purge any executions left over from prior tests in the
        // shared CH instance. Matches the house-style used across the CH IT
        // suite (see ClickHouseExecutionStoreIT, ClickHouseStatsStoreIT, etc.).
        // TRUNCATE is synchronous, unlike ALTER ... DELETE (mutations_sync=0).
        clickHouseJdbc.execute("TRUNCATE TABLE executions");
        clickHouseJdbc.execute("TRUNCATE TABLE processor_executions");

        // Default: empty registry — all evaluators return Clear
        when(agentRegistryService.findAll()).thenReturn(List.of());

        envId   = UUID.randomUUID();
        ruleId  = UUID.randomUUID();
        envSlug = "eval-it-env-" + envId;

        jdbcTemplate.update(
            "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
            envId, envSlug, "Eval IT Env");
        jdbcTemplate.update(
            "INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
            SYS_USER, SYS_USER + "@test.example.com");

        // Rule: AGENT_STATE = DEAD, forSeconds=60, forDurationSeconds=0 (straight to FIRING)
        var condition = new AgentStateCondition(
                new AlertScope(APP_SLUG, null, null), "DEAD", 60);
        var rule = new AlertRule(
                ruleId, envId, "dead-agent-rule", "fires when orders agent is dead",
                AlertSeverity.WARNING, true, ConditionKind.AGENT_STATE, condition,
                60, 0, 60,
                "Agent dead: {{agent.name}}", "Agent {{agent.id}} is {{agent.state}}",
                List.of(), List.of(),
                Instant.now().minusSeconds(5), // due now
                null, null, Map.of(),
                Instant.now(), SYS_USER, Instant.now(), SYS_USER);
        ruleRepo.save(rule);
    }

    @AfterEach
    void cleanup() {
        // Always reset the fault-injector — a prior @Test may have left it armed,
        // and Spring reuses the same context (and thus the same decorator bean)
        // across tests in this class.
        faultInjectingNotificationRepo.clearFault();
        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_rules WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
        jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", SYS_USER);
        // ClickHouse `executions` is truncated in @BeforeEach (house style).
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

    private AgentInfo deadAgent(Instant lastHeartbeat) {
        return new AgentInfo(AGENT_ID, "orders-service", APP_SLUG,
                envId.toString(), "1.0", List.of(), Map.of(),
                AgentState.DEAD, lastHeartbeat.minusSeconds(300), lastHeartbeat, null);
    }

    /**
     * Seed one FAILED execution row into the ClickHouse {@code executions} table,
     * scoped to this test's tenant/env/app so it's picked up by a PER_EXCHANGE rule
     * targeting {@code APP_SLUG}. Executions older than {@code rule.createdAt} are
     * filtered out by the evaluator; callers must pick {@code startTime} accordingly.
     */
    private void seedFailedExecution(String executionId, Instant startTime) {
        executionStore.insertExecutionBatch(List.of(new MergedExecution(
                "default", 1L, executionId, "route-a", "inst-1", APP_SLUG, envSlug,
                "FAILED", "", "exchange-" + executionId,
                startTime, startTime.plusMillis(100), 100L,
                "", "", "", "", "", "",     // errorMessage..rootCauseMessage
                "", "FULL",                 // diagramContentHash, engineLevel
                "", "", "", "", "", "",     // bodies / headers / properties
                "{}",                        // attributes (JSON)
                "", "",                     // traceId, spanId
                false, false,
                null, null
        )));
    }

    /**
     * Create a PER_EXCHANGE rule targeting {@code APP_SLUG} + status=FAILED with a
     * single webhook binding. Returns the persisted rule id.
     * <p>
     * {@code createdAt} is set {@code 60s} before {@code Instant.now()} so the
     * evaluator's first-run lower bound ({@code timeFrom = rule.createdAt}) picks
     * up the seeded executions.
     */
    private UUID createPerExchangeRuleWithWebhook() {
        UUID ruleId2 = UUID.randomUUID();
        Instant now = Instant.now();
        var condition = new ExchangeMatchCondition(
                new AlertScope(APP_SLUG, null, null),
                new ExchangeMatchCondition.ExchangeFilter("FAILED", Map.of()),
                FireMode.PER_EXCHANGE, null, null);
        var webhook = new WebhookBinding(UUID.randomUUID(), null, null, Map.of());
        var rule = new AlertRule(
                ruleId2, envId, "per-exchange-rule-" + ruleId2, 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), SYS_USER,      // createdAt — bounds first-run cursor
                now.minusSeconds(60), SYS_USER);
        ruleRepo.save(rule);
        return ruleId2;
    }

    /** List all notifications enqueued for any instance of {@code ruleId}. */
    private List<AlertNotification> listNotificationsByRule(UUID ruleId) {
        List<UUID> instanceIds = jdbcTemplate.queryForList(
                "SELECT id FROM alert_instances WHERE rule_id = ?",
                UUID.class, ruleId);
        List<AlertNotification> out = new java.util.ArrayList<>();
        for (UUID iid : instanceIds) {
            out.addAll(notificationRepo.listForInstance(iid));
        }
        return out;
    }

    /** List all instances for {@code ruleId} (open or resolved). */
    private int countInstancesByRule(UUID ruleId) {
        Long c = jdbcTemplate.queryForObject(
                "SELECT count(*) FROM alert_instances WHERE rule_id = ?",
                Long.class, ruleId);
        return c == null ? 0 : c.intValue();
    }

    // -------------------------------------------------------------------------
    // Tests
    // -------------------------------------------------------------------------

    @Test
    void noMatchingAgentProducesNoInstance() {
        // Registry empty → evaluator returns Clear → no alert_instance
        when(agentRegistryService.findAll()).thenReturn(List.of());

        job.tick();

        assertThat(instanceRepo.findOpenForRule(ruleId)).isEmpty();
    }

    @Test
    void deadAgentProducesFiringInstance() {
        // Agent has been DEAD for 2 minutes (> forSeconds=60) → FIRING
        when(agentRegistryService.findAll())
                .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120))));

        job.tick();

        assertThat(instanceRepo.findOpenForRule(ruleId)).hasValueSatisfying(i -> {
            assertThat(i.state()).isEqualTo(AlertState.FIRING);
            assertThat(i.ruleId()).isEqualTo(ruleId);
            assertThat(i.environmentId()).isEqualTo(envId);
            assertThat(i.severity()).isEqualTo(AlertSeverity.WARNING);
        });
    }

    @Test
    void claimDueResolveCycle() {
        // Tick 1: dead agent → FIRING
        when(agentRegistryService.findAll())
                .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120))));
        job.tick();
        assertThat(instanceRepo.findOpenForRule(ruleId)).hasValueSatisfying(i ->
                assertThat(i.state()).isEqualTo(AlertState.FIRING));

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

        // Tick 2: empty registry → Clear → RESOLVED
        when(agentRegistryService.findAll()).thenReturn(List.of());
        job.tick();

        assertThat(instanceRepo.findOpenForRule(ruleId)).isEmpty();
        long resolvedCount = jdbcTemplate.queryForObject(
            "SELECT count(*) FROM alert_instances WHERE rule_id = ? AND state = 'RESOLVED'",
            Long.class, ruleId);
        assertThat(resolvedCount).isEqualTo(1L);
    }

    @Test
    void firingWithForDurationCreatesPendingThenPromotes() {
        UUID ruleId2 = UUID.randomUUID();
        var condition = new AgentStateCondition(new AlertScope(APP_SLUG, null, null), "DEAD", 60);
        var ruleWithDuration = new AlertRule(
                ruleId2, envId, "pending-rule", null,
                AlertSeverity.WARNING, true, ConditionKind.AGENT_STATE, condition,
                60, 60, 60,          // forDurationSeconds = 60
                "title", "msg",
                List.of(), List.of(),
                Instant.now().minusSeconds(5),
                null, null, Map.of(),
                Instant.now(), SYS_USER, Instant.now(), SYS_USER);
        ruleRepo.save(ruleWithDuration);

        // Dead agent for both rules
        when(agentRegistryService.findAll())
                .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120))));
        job.tick();

        // ruleId2 has forDuration=60 → PENDING
        assertThat(instanceRepo.findOpenForRule(ruleId2)).hasValueSatisfying(i ->
                assertThat(i.state()).isEqualTo(AlertState.PENDING));

        // Backdate firedAt so promotion window is met
        jdbcTemplate.update(
            "UPDATE alert_instances SET fired_at = now() - interval '90 seconds' WHERE rule_id = ?",
            ruleId2);
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
            "claimed_by = NULL, claimed_until = NULL WHERE id = ?", ruleId2);

        job.tick();

        assertThat(instanceRepo.findOpenForRule(ruleId2)).hasValueSatisfying(i ->
                assertThat(i.state()).isEqualTo(AlertState.FIRING));

        jdbcTemplate.update("DELETE FROM alert_instances WHERE rule_id = ?", ruleId2);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", ruleId2);
    }

    @Test
    void ruleSnapshotIsPersistedOnInstanceCreation() {
        // Dead agent → FIRING instance created
        when(agentRegistryService.findAll())
                .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120))));

        job.tick();

        // Read rule_snapshot directly from the DB — must contain name, severity, conditionKind
        String snapshot = jdbcTemplate.queryForObject(
                "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id = ?",
                String.class, ruleId);

        assertThat(snapshot).isNotNull();
        assertThat(snapshot).contains("\"name\": \"dead-agent-rule\"");
        assertThat(snapshot).contains("\"severity\": \"WARNING\"");
        assertThat(snapshot).contains("\"conditionKind\": \"AGENT_STATE\"");
    }

    @Test
    void historySurvivesRuleDelete() {
        // Seed: dead agent → FIRING instance created
        when(agentRegistryService.findAll())
                .thenReturn(List.of(deadAgent(Instant.now().minusSeconds(120))));
        job.tick();

        // Verify instance exists with a populated snapshot
        String snapshotBefore = jdbcTemplate.queryForObject(
                "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id = ?",
                String.class, ruleId);
        assertThat(snapshotBefore).contains("\"name\": \"dead-agent-rule\"");

        // Delete the rule — ON DELETE SET NULL clears rule_id on the instance
        ruleRepo.delete(ruleId);

        // rule_id must be NULL on the instance row
        Long nullRuleIdCount = jdbcTemplate.queryForObject(
                "SELECT count(*) FROM alert_instances WHERE rule_id IS NULL AND rule_snapshot::text LIKE '%dead-agent-rule%'",
                Long.class);
        assertThat(nullRuleIdCount).isEqualTo(1L);

        // snapshot still contains the rule name — history survives deletion
        String snapshotAfter = jdbcTemplate.queryForObject(
                "SELECT rule_snapshot::text FROM alert_instances WHERE rule_id IS NULL AND rule_snapshot::text LIKE '%dead-agent-rule%'",
                String.class);
        assertThat(snapshotAfter).contains("\"name\": \"dead-agent-rule\"");
        assertThat(snapshotAfter).contains("\"severity\": \"WARNING\"");
    }

    // -------------------------------------------------------------------------
    // PER_EXCHANGE regression pin — notifications must not re-enqueue for
    // already-matched exchanges across tick boundaries (cursor must be persisted
    // via releaseClaim, then read back on the next tick).
    // See: docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md
    // -------------------------------------------------------------------------

    @Test
    void tick2_noNewExchanges_enqueuesZeroAdditionalNotifications() {
        // Arrange: 2 FAILED executions in ClickHouse, 1 PER_EXCHANGE rule with 1 webhook.
        // Use relative-to-now timestamps so they sort within the evaluator's
        // [rule.createdAt .. ctx.now()] window.
        Instant t0 = Instant.now().minusSeconds(30);
        seedFailedExecution("exec-1", t0);
        seedFailedExecution("exec-2", t0.plusSeconds(1));
        UUID perExRuleId = createPerExchangeRuleWithWebhook();

        // Tick 1 — expect 2 instances, 2 notifications.
        job.tick();
        assertThat(countInstancesByRule(perExRuleId))
                .as("tick 1 must create one FIRING instance per matched exchange")
                .isEqualTo(2);
        List<AlertNotification> afterTick1 = listNotificationsByRule(perExRuleId);
        assertThat(afterTick1)
                .as("tick 1 must enqueue one notification per instance (1 webhook × 2 instances)")
                .hasSize(2);

        // Simulate NotificationDispatchJob draining the queue.
        Instant now = Instant.now();
        afterTick1.forEach(n -> notificationRepo.markDelivered(n.id(), 200, "OK", now));

        // Reopen the claim so the rule is 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);

        // Tick 2 — no new ClickHouse rows; cursor should have advanced past exec-2
        // during tick 1 and persisted via releaseClaim. Therefore: no new firings,
        // no new notifications.
        job.tick();

        // Instance count unchanged.
        assertThat(countInstancesByRule(perExRuleId))
                .as("tick 2 must not create new instances — cursor persisted past exec-2")
                .isEqualTo(2);

        // THE BLEED: any new PENDING notification after tick 2 indicates the
        // evaluator re-matched already-processed exchanges (cursor not persisted
        // across ticks). Must be zero after the Phase 1 fix.
        long pending = listNotificationsByRule(perExRuleId).stream()
                .filter(n -> n.status() == NotificationStatus.PENDING)
                .count();
        assertThat(pending)
                .as("tick 2 must NOT re-enqueue notifications for already-matched exchanges")
                .isZero();

        // Clean up the extra rule (setup-created rule is handled by @AfterEach).
        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_rules WHERE id = ?", perExRuleId);
    }

    // -------------------------------------------------------------------------
    // Tick atomicity regression pin — a crash mid-batch must roll back every
    // instance + notification write AND leave the cursor unchanged so the
    // next tick re-processes the entire batch exactly once.
    // See: docs/superpowers/plans/2026-04-22-per-exchange-exactly-once.md (Task 2.1)
    // -------------------------------------------------------------------------

    @Test
    void tickRollback_faultOnSecondNotificationInsert_leavesCursorUnchanged() {
        // Seed 3 FAILED executions so the rule's PER_EXCHANGE batch has 3 firings.
        // Relative-to-now timestamps so they fall inside [rule.createdAt .. ctx.now()].
        Instant t0 = Instant.now().minusSeconds(30);
        seedFailedExecution("exec-1", t0);
        seedFailedExecution("exec-2", t0.plusSeconds(1));
        seedFailedExecution("exec-3", t0.plusSeconds(2));
        UUID perExRuleId = createPerExchangeRuleWithWebhook();

        var ruleBefore = ruleRepo.findById(perExRuleId).orElseThrow();
        Object cursorBefore = ruleBefore.evalState().get("lastExchangeCursor"); // null on first run
        Instant nextRunBefore = ruleBefore.nextEvaluationAt();

        // Arm the fault injector: the 2nd notification save() throws.
        // (Instance saves are NOT counted — the injector is scoped to notification saves.)
        faultInjectingNotificationRepo.failOnSave(2);

        // Today (Phase 1, non-transactional): the evaluator catches the exception
        // per-rule and logs a warning — see AlertEvaluatorJob#tick's try/catch
        // around applyResult. So tick() itself does NOT rethrow. That is exactly
        // why this IT is RED pre-Phase-2: post-rollback asserts expect 0 instances
        // and 0 notifications, but the current code will have persisted
        // firing #1 (instance + notification) and firing #2's instance before the
        // fault on firing #2's notification. Phase 2 wraps the per-rule body in
        // @Transactional so the single-rule failure rolls back all of its writes.
        try {
            job.tick();
        } catch (RuntimeException expectedAfterPhase2) {
            // Phase 2 may choose to rethrow; either way the rollback assertions
            // below are what pin the contract.
            // intentionally empty — fault-injection swallow/rethrow tolerance; see comment above
        }

        // Post-rollback: zero instances, zero notifications, cursor unchanged,
        // nextRunAt unchanged (Phase 2 will hold the claim so the next tick retries).
        assertThat(countInstancesByRule(perExRuleId))
                .as("Phase 2 contract: mid-batch fault rolls back every instance write")
                .isZero();
        assertThat(listNotificationsByRule(perExRuleId))
                .as("Phase 2 contract: mid-batch fault rolls back every notification write")
                .isEmpty();
        var ruleAfter = ruleRepo.findById(perExRuleId).orElseThrow();
        assertThat(ruleAfter.evalState().get("lastExchangeCursor"))
                .as("Phase 2 contract: cursor MUST NOT advance when the tick fails")
                .isEqualTo(cursorBefore);
        assertThat(ruleAfter.nextEvaluationAt())
                .as("Phase 2 contract: nextEvaluationAt MUST NOT advance when the tick fails")
                .isEqualTo(nextRunBefore);

        // Recover: clear the fault, reopen the claim, tick again.
        // All 3 firings must land on the second tick — exactly-once-per-exchange.
        faultInjectingNotificationRepo.clearFault();
        jdbcTemplate.update(
            "UPDATE alert_rules SET next_evaluation_at = now() - interval '1 second', " +
            "claimed_by = NULL, claimed_until = NULL WHERE id = ?", perExRuleId);

        job.tick();

        assertThat(countInstancesByRule(perExRuleId))
                .as("after recovery: all 3 exchanges produce exactly one instance each")
                .isEqualTo(3);
        assertThat(listNotificationsByRule(perExRuleId))
                .as("after recovery: all 3 instances produce exactly one notification each")
                .hasSize(3);
        assertThat(ruleRepo.findById(perExRuleId).orElseThrow()
                .evalState().get("lastExchangeCursor"))
                .as("after recovery: cursor advanced past exec-3")
                .isNotEqualTo(cursorBefore);

        // Clean up the extra rule (setup-created rule is handled by @AfterEach).
        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_rules WHERE id = ?", perExRuleId);
    }

    // -------------------------------------------------------------------------
    // Fault-injecting AlertNotificationRepository decorator
    //
    // Delegates all calls to the real Postgres-backed repository except
    // {@link #save(AlertNotification)} — that method increments a counter and
    // throws a RuntimeException when the configured trigger-count is reached.
    // Only notification saves are counted; instance saves go through a separate
    // repo and are unaffected.
    // -------------------------------------------------------------------------

    static class FaultInjectingNotificationRepository implements AlertNotificationRepository {
        private final AlertNotificationRepository delegate;
        private final AtomicInteger saveCount = new AtomicInteger(0);
        private volatile int failOnNthSave = -1; // -1 = disabled

        FaultInjectingNotificationRepository(AlertNotificationRepository delegate) {
            this.delegate = delegate;
        }

        /** Arms the fault: the {@code n}-th call to {@link #save} (1-indexed) throws. */
        void failOnSave(int n) {
            saveCount.set(0);
            failOnNthSave = n;
        }

        /** Disarms the fault and resets the counter. */
        void clearFault() {
            failOnNthSave = -1;
            saveCount.set(0);
        }

        @Override
        public AlertNotification save(AlertNotification n) {
            int current = saveCount.incrementAndGet();
            if (failOnNthSave > 0 && current == failOnNthSave) {
                throw new RuntimeException(
                        "FaultInjectingNotificationRepository: injected failure on save #" + current);
            }
            return delegate.save(n);
        }

        @Override
        public Optional<AlertNotification> findById(UUID id) { return delegate.findById(id); }

        @Override
        public List<AlertNotification> listForInstance(UUID alertInstanceId) {
            return delegate.listForInstance(alertInstanceId);
        }

        @Override
        public List<AlertNotification> claimDueNotifications(String instanceId, int batchSize, int claimTtlSeconds) {
            return delegate.claimDueNotifications(instanceId, batchSize, claimTtlSeconds);
        }

        @Override
        public void markDelivered(UUID id, int status, String snippet, Instant when) {
            delegate.markDelivered(id, status, snippet, when);
        }

        @Override
        public void scheduleRetry(UUID id, Instant nextAttemptAt, int status, String snippet) {
            delegate.scheduleRetry(id, nextAttemptAt, status, snippet);
        }

        @Override
        public void markFailed(UUID id, int status, String snippet) {
            delegate.markFailed(id, status, snippet);
        }

        @Override
        public void resetForRetry(UUID id, Instant nextAttemptAt) {
            delegate.resetForRetry(id, nextAttemptAt);
        }

        @Override
        public void deleteSettledBefore(Instant cutoff) {
            delegate.deleteSettledBefore(cutoff);
        }
    }

    /**
     * {@link TestConfiguration} that installs the fault-injecting decorator as the
     * {@code @Primary} {@link AlertNotificationRepository}. The real Postgres repo is
     * still registered (via {@code AlertingBeanConfig}) and is injected into the
     * decorator as the delegate, so every non-instrumented call path exercises real SQL.
     */
    @TestConfiguration
    static class FaultInjectingNotificationRepoConfig {
        @Bean
        @Primary
        FaultInjectingNotificationRepository faultInjectingNotificationRepository(
                @Qualifier("alertNotificationRepository") AlertNotificationRepository realRepo) {
            return new FaultInjectingNotificationRepository(realRepo);
        }
    }
}
