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

import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;

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

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

class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {

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

    private PostgresAlertInstanceRepository repo;
    private UUID envId;
    private UUID otherEnvId;
    private UUID ruleId;
    private final String userId = "inbox-user-" + UUID.randomUUID();
    private final String groupId = UUID.randomUUID().toString();
    private final String roleName = "OPERATOR";

    @BeforeEach
    void setup() {
        repo = new PostgresAlertInstanceRepository(jdbcTemplate, new ObjectMapper());
        envId = UUID.randomUUID();
        otherEnvId = UUID.randomUUID();
        ruleId = UUID.randomUUID();

        jdbcTemplate.update(
            "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
            envId, "test-env-" + UUID.randomUUID(), "Test Env");
        jdbcTemplate.update(
            "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
            otherEnvId, "other-env-" + UUID.randomUUID(), "Other Env");
        jdbcTemplate.update(
            "INSERT INTO users (user_id, provider, email) VALUES (?, 'local', ?) ON CONFLICT (user_id) DO NOTHING",
            userId, userId + "@example.com");
        jdbcTemplate.update(
            "INSERT INTO users (user_id, provider, email) VALUES ('sys-user', 'local', 'sys@example.com') ON CONFLICT (user_id) DO NOTHING");
        jdbcTemplate.update(
            "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
            "notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
            "VALUES (?, ?, 'rule', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
            ruleId, envId);
    }

    @AfterEach
    void cleanup() {
        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_notifications WHERE alert_instance_id IN " +
            "(SELECT id FROM alert_instances WHERE environment_id = ?)", otherEnvId);
        jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", otherEnvId);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", envId);
        jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id = ?", otherEnvId);
        jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
        jdbcTemplate.update("DELETE FROM environments WHERE id = ?", otherEnvId);
        jdbcTemplate.update("DELETE FROM users WHERE user_id = ?", userId);
    }

    @Test
    void saveAndFindByIdRoundtrip() {
        var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(inst);

        var found = repo.findById(inst.id()).orElseThrow();
        assertThat(found.id()).isEqualTo(inst.id());
        assertThat(found.state()).isEqualTo(AlertState.FIRING);
        assertThat(found.severity()).isEqualTo(AlertSeverity.WARNING);
        assertThat(found.targetUserIds()).containsExactly(userId);
        assertThat(found.targetGroupIds()).isEmpty();
        assertThat(found.targetRoleNames()).isEmpty();
    }

    @Test
    void listForInbox_seesAllThreeTargetTypes() {
        // Each instance gets a distinct ruleId so the unique-per-open-rule index
        // (V13: alert_instances_open_rule_uq) doesn't block the second and third saves.
        UUID ruleId2 = seedRule("rule-b");
        UUID ruleId3 = seedRule("rule-c");

        // Instance 1 — targeted at user directly
        var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
        // Instance 2 — targeted at group
        var byGroup = newInstance(ruleId2, List.of(), List.of(UUID.fromString(groupId)), List.of());
        // Instance 3 — targeted at role
        var byRole = newInstance(ruleId3, List.of(), List.of(), List.of(roleName));

        repo.save(byUser);
        repo.save(byGroup);
        repo.save(byRole);

        // User is member of the group AND has the role
        var inbox = repo.listForInbox(envId, List.of(groupId), userId, List.of(roleName), null, null, null, null, 50);
        assertThat(inbox).extracting(AlertInstance::id)
            .containsExactlyInAnyOrder(byUser.id(), byGroup.id(), byRole.id());
    }

    @Test
    void listForInbox_emptyGroupsAndRoles() {
        var byUser = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(byUser);

        var inbox = repo.listForInbox(envId, List.of(), userId, List.of(), null, null, null, null, 50);
        assertThat(inbox).hasSize(1);
        assertThat(inbox.get(0).id()).isEqualTo(byUser.id());
    }

    @Test
    void countUnreadBySeverity_decreasesAfterMarkRead() {
        var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(inst);

        var before = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
        assertThat(before)
            .containsEntry(AlertSeverity.WARNING,  1L)
            .containsEntry(AlertSeverity.CRITICAL, 0L)
            .containsEntry(AlertSeverity.INFO,     0L);

        repo.markRead(inst.id(), Instant.now());

        var after = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
        assertThat(after.values()).allMatch(v -> v == 0L);
    }

    @Test
    void countUnreadBySeverity_groupsBySeverity() {
        // Each open instance needs its own rule to satisfy V13's unique partial index.
        UUID critRule = seedRuleWithSeverity("crit", AlertSeverity.CRITICAL);
        UUID warnRule = seedRuleWithSeverity("warn", AlertSeverity.WARNING);
        UUID infoRule = seedRuleWithSeverity("info", AlertSeverity.INFO);

        repo.save(newInstance(critRule, AlertSeverity.CRITICAL, List.of(userId), List.of(), List.of()));
        repo.save(newInstance(warnRule, AlertSeverity.WARNING,  List.of(userId), List.of(), List.of()));
        repo.save(newInstance(infoRule, AlertSeverity.INFO,     List.of(userId), List.of(), List.of()));

        var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());

        assertThat(counts)
            .containsEntry(AlertSeverity.CRITICAL, 1L)
            .containsEntry(AlertSeverity.WARNING,  1L)
            .containsEntry(AlertSeverity.INFO,     1L);
    }

    @Test
    void countUnreadBySeverity_emptyMapStillHasAllKeys() {
        // No instances saved — every severity must still be present with value 0
        // so callers never deal with null/missing keys.
        var counts = repo.countUnreadBySeverity(envId, userId, List.of(), List.of());
        assertThat(counts).hasSize(3);
        assertThat(counts.values()).allMatch(v -> v == 0L);
    }

    @Test
    void findOpenForRule_excludesResolved() {
        var open = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(open);

        assertThat(repo.findOpenForRule(ruleId)).isPresent();

        repo.resolve(open.id(), Instant.now());

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

    @Test
    void ack_setsAckedAtAndLeavesStateFiring() {
        var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(inst);

        Instant when = Instant.now();
        repo.ack(inst.id(), userId, when);

        var found = repo.findById(inst.id()).orElseThrow();
        assertThat(found.state()).isEqualTo(AlertState.FIRING);
        assertThat(found.ackedBy()).isEqualTo(userId);
        assertThat(found.ackedAt()).isNotNull();
    }

    @Test
    void resolve_setsResolvedAtAndState() {
        var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(inst);

        repo.resolve(inst.id(), Instant.now());

        var found = repo.findById(inst.id()).orElseThrow();
        assertThat(found.state()).isEqualTo(AlertState.RESOLVED);
        assertThat(found.resolvedAt()).isNotNull();
    }

    @Test
    void deleteResolvedBefore_deletesOnlyResolved() {
        UUID ruleId2 = seedRule("rule-del");
        var firing = newInstance(ruleId, List.of(userId), List.of(), List.of());
        var resolved = newInstance(ruleId2, List.of(userId), List.of(), List.of());
        repo.save(firing);
        repo.save(resolved);

        Instant resolvedTime = Instant.now().minusSeconds(10);
        repo.resolve(resolved.id(), resolvedTime);

        repo.deleteResolvedBefore(Instant.now());

        assertThat(repo.findById(firing.id())).isPresent();
        assertThat(repo.findById(resolved.id())).isEmpty();
    }

    @Test
    void listFiringDueForReNotify_returnsOnlyEligibleInstances() {
        // Each instance gets its own rule — the V13 unique partial index allows only one
        // open (PENDING/FIRING/ACKNOWLEDGED) instance per rule_id.
        UUID ruleNever   = seedReNotifyRule("renotify-never");
        UUID ruleLongAgo = seedReNotifyRule("renotify-longago");
        UUID ruleRecent  = seedReNotifyRule("renotify-recent");

        // Instance 1: FIRING, never notified (last_notified_at IS NULL) → must NOT appear.
        // The sweep only re-notifies; initial notification is the dispatcher's job.
        var neverNotified = newInstance(ruleNever, List.of(userId), List.of(), List.of());
        repo.save(neverNotified);

        // Instance 2: FIRING, notified 2 minutes ago → cadence elapsed, must appear
        var notifiedLongAgo = newInstance(ruleLongAgo, List.of(userId), List.of(), List.of());
        repo.save(notifiedLongAgo);
        jdbcTemplate.update("UPDATE alert_instances SET last_notified_at = now() - interval '2 minutes' WHERE id = ?",
            notifiedLongAgo.id());

        // Instance 3: FIRING, notified 30 seconds ago → cadence NOT elapsed, must NOT appear
        var notifiedRecently = newInstance(ruleRecent, List.of(userId), List.of(), List.of());
        repo.save(notifiedRecently);
        jdbcTemplate.update("UPDATE alert_instances SET last_notified_at = now() - interval '30 seconds' WHERE id = ?",
            notifiedRecently.id());

        var due = repo.listFiringDueForReNotify(Instant.now());
        assertThat(due).extracting(AlertInstance::id)
            .containsExactly(notifiedLongAgo.id())
            .doesNotContain(neverNotified.id(), notifiedRecently.id());

        // Extra rules are cleaned up by @AfterEach via env-scoped DELETE
    }

    @Test
    void save_allowsMultipleOpenInstancesForSameRuleWithDifferentExchangeIds() {
        // PER_EXCHANGE mode (EXCHANGE_MATCH rules) emits one alert_instance per
        // matching exchange — the design requires many open instances per rule,
        // each carrying a distinct exchange.id in its context. The open-rule
        // partial unique must therefore discriminate on (rule_id, exchange.id),
        // not on rule_id alone. Regression: V13's original (rule_id)-only
        // constraint masked this by catching DuplicateKeyException and silently
        // dropping every per-exchange firing after the first — logs filled with
        // "Skipped duplicate open alert_instance for rule …" at evaluator cadence
        // and PER_EXCHANGE notifications were never dispatched.

        var exchangeA = newInstanceWithExchange(ruleId, "exchange-aaa");
        var exchangeB = newInstanceWithExchange(ruleId, "exchange-bbb");
        var exchangeC = newInstanceWithExchange(ruleId, "exchange-ccc");

        repo.save(exchangeA);
        repo.save(exchangeB);
        repo.save(exchangeC);

        assertThat(repo.findById(exchangeA.id())).isPresent();
        assertThat(repo.findById(exchangeB.id())).isPresent();
        assertThat(repo.findById(exchangeC.id())).isPresent();

        Long count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM alert_instances " +
            " WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
            Long.class, ruleId);
        assertThat(count).isEqualTo(3L);
    }

    @Test
    void save_rejectsSecondOpenInstanceForSameRuleAndExchange() {
        // The partial unique still prevents genuine duplicates: two open instances
        // for the same (rule, exchange) pair must collapse to one. The repo's
        // DuplicateKeyException fallback returns the already-persisted instance.

        var first  = newInstanceWithExchange(ruleId, "exchange-dup");
        var second = newInstanceWithExchange(ruleId, "exchange-dup");

        repo.save(first);
        var returned = repo.save(second);

        // Second insert was suppressed; the repo returned the existing instance.
        assertThat(returned.id()).isEqualTo(first.id());
        assertThat(repo.findById(first.id())).isPresent();
        assertThat(repo.findById(second.id())).isEmpty();

        Long count = jdbcTemplate.queryForObject(
            "SELECT COUNT(*) FROM alert_instances " +
            " WHERE rule_id = ? AND state IN ('PENDING','FIRING')",
            Long.class, ruleId);
        assertThat(count).isEqualTo(1L);
    }

    @Test
    void markSilenced_togglesToTrue() {
        var inst = newInstance(ruleId, List.of(userId), List.of(), List.of());
        repo.save(inst);

        assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isFalse();
        repo.markSilenced(inst.id(), true);
        assertThat(repo.findById(inst.id()).orElseThrow().silenced()).isTrue();
    }

    @Test
    void markRead_is_idempotent_and_sets_read_at() {
        var inst = insertFreshFiring();
        repo.markRead(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
        repo.markRead(inst.id(), Instant.parse("2026-04-21T11:00:00Z")); // idempotent — no-op
        var loaded = repo.findById(inst.id()).orElseThrow();
        assertThat(loaded.readAt()).isEqualTo(Instant.parse("2026-04-21T10:00:00Z"));
    }

    @Test
    void softDelete_excludes_from_listForInbox() {
        var inst = insertFreshFiring();
        repo.softDelete(inst.id(), Instant.parse("2026-04-21T10:00:00Z"));
        var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
                null, null, null, null, 100);
        assertThat(rows).extracting(AlertInstance::id).doesNotContain(inst.id());
    }

    @Test
    void findOpenForRule_returns_acked_firing() {
        var inst = insertFreshFiring();
        repo.ack(inst.id(), userId, Instant.parse("2026-04-21T10:00:00Z"));
        var open = repo.findOpenForRule(inst.ruleId());
        assertThat(open).isPresent(); // ack no longer closes the open slot — state stays FIRING
    }

    @Test
    void findOpenForRule_skips_soft_deleted() {
        var inst = insertFreshFiring();
        repo.softDelete(inst.id(), Instant.now());
        assertThat(repo.findOpenForRule(inst.ruleId())).isEmpty();
    }

    @Test
    void bulk_ack_only_touches_unacked_rows() {
        var a = insertFreshFiring();
        var b = insertFreshFiring();
        // ack 'a' first with userId; bulkAck should leave 'a' untouched (already acked)
        repo.ack(a.id(), userId, Instant.parse("2026-04-21T09:00:00Z"));
        repo.bulkAck(List.of(a.id(), b.id()), userId, Instant.parse("2026-04-21T10:00:00Z"));
        // a was already acked — acked_at stays at the first timestamp, not updated again
        assertThat(repo.findById(a.id()).orElseThrow().ackedBy()).isEqualTo(userId);
        assertThat(repo.findById(b.id()).orElseThrow().ackedBy()).isEqualTo(userId);
    }

    @Test
    void listForInbox_acked_false_hides_acked_rows() {
        var a = insertFreshFiring();
        var b = insertFreshFiring();
        repo.ack(a.id(), userId, Instant.now());
        var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
                null, null, /*acked*/ false, null, 100);
        assertThat(rows).extracting(AlertInstance::id).doesNotContain(a.id());
        assertThat(rows).extracting(AlertInstance::id).contains(b.id());
    }

    @Test
    void restore_clears_deleted_at() {
        var inst = insertFreshFiring();
        repo.softDelete(inst.id(), Instant.now());
        repo.restore(inst.id());
        var loaded = repo.findById(inst.id()).orElseThrow();
        assertThat(loaded.deletedAt()).isNull();
        var rows = repo.listForInbox(envId, List.of(), userId, List.of(),
                null, null, null, null, 100);
        assertThat(rows).extracting(AlertInstance::id).contains(inst.id());
    }

    @Test
    void filterInEnvLive_excludes_other_env_and_soft_deleted() {
        var a = insertFreshFiring();                        // env envId, live
        var b = insertFreshFiring();                        // env envId, will be soft-deleted
        repo.softDelete(b.id(), Instant.now());

        UUID unknownId = UUID.randomUUID();                 // not in DB at all

        // Insert a rule + instance in the second environment (otherEnvId) to prove
        // that the SQL env-filter actually excludes rows from a different environment.
        UUID otherRuleId = seedRuleInEnv("other-rule", otherEnvId);
        var otherEnvInst = newInstanceInEnv(otherRuleId, otherEnvId, List.of(userId), List.of(), List.of());
        repo.save(otherEnvInst);

        var kept = repo.filterInEnvLive(List.of(a.id(), b.id(), unknownId, otherEnvInst.id()), envId);
        assertThat(kept).containsExactly(a.id());
        assertThat(kept).doesNotContain(otherEnvInst.id());
    }

    @Test
    void bulkMarkRead_respects_deleted_at() {
        var live = insertFreshFiring();
        // second instance — need a fresh ruleId due to the open-rule unique index
        UUID ruleId2 = seedRule("rule-deleted");
        var deleted = newInstance(ruleId2, List.of(userId), List.of(), List.of());
        repo.save(deleted);

        repo.softDelete(deleted.id(), Instant.parse("2026-04-21T10:00:00Z"));

        repo.bulkMarkRead(List.of(live.id(), deleted.id()), Instant.parse("2026-04-21T10:05:00Z"));

        // live row is marked read
        assertThat(repo.findById(live.id()).orElseThrow().readAt())
                .isEqualTo(Instant.parse("2026-04-21T10:05:00Z"));
        // soft-deleted row is NOT touched by bulkMarkRead
        assertThat(repo.findById(deleted.id()).orElseThrow().readAt()).isNull();
    }

    // -------------------------------------------------------------------------

    /** Creates and saves a fresh FIRING instance targeted at the test userId with its own rule. */
    private AlertInstance insertFreshFiring() {
        UUID freshRuleId = seedRule("fresh-rule");
        var inst = newInstance(freshRuleId, List.of(userId), List.of(), List.of());
        return repo.save(inst);
    }

    private AlertInstance newInstance(UUID ruleId,
                                      List<String> userIds,
                                      List<UUID> groupIds,
                                      List<String> roleNames) {
        return newInstance(ruleId, AlertSeverity.WARNING, userIds, groupIds, roleNames);
    }

    private AlertInstance newInstance(UUID ruleId,
                                      AlertSeverity severity,
                                      List<String> userIds,
                                      List<UUID> groupIds,
                                      List<String> roleNames) {
        return new AlertInstance(
            UUID.randomUUID(), ruleId, Map.of(), envId,
            AlertState.FIRING, severity,
            Instant.now(), null, null, null, null, null, null,
            false, null, null,
            Map.of(), "title", "message",
            userIds, groupIds, roleNames);
    }

    /**
     * Creates an open FIRING instance carrying a PER_EXCHANGE-shaped context:
     * {@code context.exchange.id = <exchangeId>}. Mirrors what
     * {@code ExchangeMatchEvaluator.evaluatePerExchange} emits for each matching
     * exchange in a Batch result.
     */
    private AlertInstance newInstanceWithExchange(UUID ruleId, String exchangeId) {
        return new AlertInstance(
            UUID.randomUUID(), ruleId, Map.of(), envId,
            AlertState.FIRING, AlertSeverity.WARNING,
            Instant.now(), null, null, null, null, null, null,
            false, null, null,
            Map.of("exchange", Map.of("id", exchangeId)),
            "title", "message",
            List.of(userId), List.of(), List.of());
    }

    /** Inserts a minimal alert_rule with the given severity. */
    private UUID seedRuleWithSeverity(String name, AlertSeverity severity) {
        UUID id = UUID.randomUUID();
        jdbcTemplate.update(
            "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
            "notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
            "VALUES (?, ?, ?, ?::severity_enum, 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
            id, envId, name + "-" + id, severity.name());
        return id;
    }

    /** Inserts a minimal alert_rule with re_notify_minutes=0 and returns its id. */
    private UUID seedRule(String name) {
        UUID id = UUID.randomUUID();
        jdbcTemplate.update(
            "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
            "notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
            "VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
            id, envId, name + "-" + id);
        return id;
    }

    /** Inserts a minimal alert_rule in a specific environment and returns its id. */
    private UUID seedRuleInEnv(String name, UUID targetEnvId) {
        UUID id = UUID.randomUUID();
        jdbcTemplate.update(
            "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
            "notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
            "VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', 'sys-user', 'sys-user')",
            id, targetEnvId, name + "-" + id);
        return id;
    }

    /** Creates an AlertInstance bound to a specific environment (not the default envId). */
    private AlertInstance newInstanceInEnv(UUID ruleId,
                                           UUID targetEnvId,
                                           List<String> userIds,
                                           List<UUID> groupIds,
                                           List<String> roleNames) {
        return new AlertInstance(
            UUID.randomUUID(), ruleId, Map.of(), targetEnvId,
            AlertState.FIRING, AlertSeverity.WARNING,
            Instant.now(), null, null, null, null, null, null,
            false, null, null,
            Map.of(), "title", "message",
            userIds, groupIds, roleNames);
    }

    /** Inserts a minimal alert_rule with re_notify_minutes=1 and returns its id. */
    private UUID seedReNotifyRule(String name) {
        UUID id = UUID.randomUUID();
        jdbcTemplate.update(
            "INSERT INTO alert_rules (id, environment_id, name, severity, condition_kind, condition, " +
            "re_notify_minutes, notification_title_tmpl, notification_message_tmpl, created_by, updated_by) " +
            "VALUES (?, ?, ?, 'WARNING', 'AGENT_STATE', '{}'::jsonb, 1, 't', 'm', 'sys-user', 'sys-user')",
            id, envId, name + "-" + id);
        return id;
    }
}
