package com.magistr.app.service;

import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigPersistenceException;
import com.magistr.app.config.tenant.TenantConfigStore;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.config.tenant.TenantSecretCompensationResult;
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;

import java.sql.Connection;
import java.sql.SQLException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class TenantLifecycleServiceTest {

    @Mock
    private TenantRoutingDataSource routingDataSource;
    @Mock
    private TenantConfigStore tenantConfigStore;
    @Mock
    private TenantDatabaseMigrationService migrationService;
    @Mock
    private RetiredTenantPoolService retiredPoolService;
    @Mock
    private TenantReadinessRegistry readinessRegistry;
    @Mock
    private HikariDataSource candidate;
    @Mock
    private HikariDataSource oldDataSource;
    @Mock
    private Connection candidateConnection;

    private TenantLifecycleService service;
    private TenantConfig oldAlpha;
    private TenantConfig beta;
    private Map<String, TenantConfig> oldSnapshot;

    @BeforeEach
    void setUp() throws Exception {
        service = new TenantLifecycleService(
                routingDataSource,
                tenantConfigStore,
                migrationService,
                retiredPoolService,
                readinessRegistry
        );
        oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
        beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-password");
        oldSnapshot = new LinkedHashMap<>();
        oldSnapshot.put("beta", beta);
        oldSnapshot.put("alpha", oldAlpha);

        when(routingDataSource.snapshotTenantConfigs()).thenReturn(oldSnapshot);
        when(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate);
        when(candidate.getConnection()).thenReturn(candidateConnection);
        when(candidateConnection.isValid(5)).thenReturn(true);
        when(tenantConfigStore.upsertTenant(any(TenantConfig.class)))
                .thenAnswer(invocation -> upsertReceipt(invocation.getArgument(0)));
        when(tenantConfigStore.removeTenant(any(String.class))).thenAnswer(invocation ->
                removeReceipt(invocation.getArgument(0)));
        when(tenantConfigStore.compensate(any())).thenReturn(TenantSecretCompensationResult.RESTORED);
        when(routingDataSource.swapTenant(any(TenantConfig.class), any()))
                .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
    }

    @Test
    void appliesSuccessfulReplacementInSafeOrderAndClosesOnlyOldPool() throws Exception {
        TenantConfig requested = config(
                "  Новый Alpha  ", "  ALPHA  ", "  jdbc:new-alpha  ", "new-user", "new-password"
        );

        TenantConfig saved = service.addOrUpdateTenant(requested);

        assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
        ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
        ArgumentCaptor<TenantConfig> persistedConfig = ArgumentCaptor.forClass(TenantConfig.class);
        InOrder order = inOrder(
                routingDataSource,
                candidate,
                candidateConnection,
                migrationService,
                tenantConfigStore,
                readinessRegistry,
                retiredPoolService
        );
        order.verify(routingDataSource).snapshotTenantConfigs();
        order.verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture());
        order.verify(candidate).getConnection();
        order.verify(candidateConnection).isValid(5);
        order.verify(candidateConnection).close();
        order.verify(migrationService).migrate(candidate);
        order.verify(tenantConfigStore).upsertTenant(persistedConfig.capture());
        order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
        order.verify(readinessRegistry).replaceDesired(any());
        order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
        order.verify(readinessRegistry).markConnectivity("alpha", true);
        order.verify(retiredPoolService).retire(oldDataSource);

        assertConfig(preparedConfig.getValue(),
                "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
        assertConfig(persistedConfig.getValue(),
                "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
        verify(candidate, never()).close();
    }

    @Test
    void returnsConfirmedReceiptWithSuccessfulUpsert() {
        TenantConfig requested = newAlpha();
        TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
                true,
                false,
                "resource-version-noop",
                List.of(requested),
                List.of(requested)
        );
        when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);

        TenantLifecycleMutationResult result = service.addOrUpdateTenantWithReceipt(requested);

        assertThat(result.receipt()).isSameAs(receipt);
        assertConfig(result.tenant(),
                "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
    }

    @Test
    void addsNewTenantWithoutRetiringExistingPool() {
        Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
        createSnapshot.put("beta", beta);
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(createSnapshot);
        when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
        TenantConfig gamma = config(
                "Новый Gamma", "  GAMMA  ", "  jdbc:gamma  ", "gamma-user", "gamma-password"
        );

        TenantConfig saved = service.addOrUpdateTenant(gamma);

        assertConfig(saved, "Новый Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password");
        ArgumentCaptor<TenantConfig> persistedConfig = ArgumentCaptor.forClass(TenantConfig.class);
        verify(tenantConfigStore).upsertTenant(persistedConfig.capture());
        assertThat(persistedConfig.getValue().getDomain()).isEqualTo("gamma");
        verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
        verify(retiredPoolService, never()).retire(any());
        verify(candidate, never()).close();
    }

    @Test
    void doesNotBlindlyCompensateWhenCreatePersistenceFails() {
        Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
        createSnapshot.put("beta", beta);
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(createSnapshot);
        when(tenantConfigStore.upsertTenant(any()))
                .thenThrow(new TenantConfigPersistenceException("Не удалось сохранить конфигурацию тенантов"));
        TenantConfig gamma = config(
                "Новый Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password"
        );

        assertThatThrownBy(() -> service.addOrUpdateTenant(gamma))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось сохранить конфигурацию тенантов");

        verify(tenantConfigStore).upsertTenant(any());
        verify(tenantConfigStore, never()).compensate(any());
        verify(candidate).close();
        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(retiredPoolService, never()).retire(any());
    }

    @Test
    void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
        when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось подключиться к базе данных тенанта");

        verify(candidate).close();
        verify(migrationService, never()).migrate(any());
        verify(tenantConfigStore, never()).upsertTenant(any());
        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(retiredPoolService, never()).retire(oldDataSource);
    }

    @Test
    void closesCandidateAndDoesNotPersistWhenConnectionIsInvalid() throws Exception {
        when(candidateConnection.isValid(5)).thenReturn(false);

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("База данных тенанта не подтвердила готовность подключения");

        verify(candidateConnection).close();
        verify(candidate).close();
        verify(migrationService, never()).migrate(any());
        verify(tenantConfigStore, never()).upsertTenant(any());
        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(retiredPoolService, never()).retire(any());
    }

    @Test
    void closesCandidateAndKeepsOldStateWhenFlywayFails() {
        org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
                .when(migrationService).migrate(candidate);

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось выполнить миграции базы данных тенанта");

        verify(candidate).close();
        verify(tenantConfigStore, never()).upsertTenant(any());
        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(retiredPoolService, never()).retire(oldDataSource);
    }

    @Test
    void persistenceConflictDoesNotStartBlindCompensation() {
        when(tenantConfigStore.upsertTenant(any())).thenThrow(new TenantConfigPersistenceException(
                "Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос"
        ));

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос");

        verify(tenantConfigStore, never()).compensate(any());
        verify(candidate).close();
        verify(routingDataSource, never()).swapTenant(any(), any());
    }

    @Test
    void unknownPersistenceFailureUsesSafeMessageAndDoesNotCompensate() {
        when(tenantConfigStore.upsertTenant(any()))
                .thenThrow(new IllegalStateException("неопределённый технический результат PUT"));

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось сохранить конфигурацию тенантов");

        verify(tenantConfigStore, never()).compensate(any());
        verify(candidate).close();
        verify(routingDataSource, never()).swapTenant(any(), any());
    }

    @Test
    void compensatesOnlyCommittedReceiptWhenActivationFails() {
        TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha());
        when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
        when(routingDataSource.swapTenant(any(), any()))
                .thenThrow(new IllegalStateException("тестовый отказ активации"));

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");

        verify(tenantConfigStore).compensate(receipt);
        verify(candidate).close();
        verify(retiredPoolService, never()).retire(oldDataSource);
    }

    @Test
    void concurrentChangeSkipsUnsafeCompensationAndMarksReadinessFailed() {
        TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha());
        when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
        when(tenantConfigStore.compensate(receipt))
                .thenReturn(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE);
        when(routingDataSource.swapTenant(any(), any()))
                .thenThrow(new IllegalStateException("тестовый отказ активации"));

        assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось активировать подключение тенанта; tenant Secret уже изменён "
                        + "другим экземпляром, автоматический откат пропущен");

        verify(readinessRegistry).markConfigurationFailure();
        verify(candidate).close();
    }

    @Test
    void removalPersistsOperationBeforeAtomicRemovalAndClosesRemovedPool() {
        when(routingDataSource.removeTenantAtomically("alpha"))
                .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));

        TenantConfig removed = service.removeTenant(" ALPHA ");

        assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
        InOrder order = inOrder(routingDataSource, tenantConfigStore, readinessRegistry, retiredPoolService);
        order.verify(routingDataSource).snapshotTenantConfigs();
        order.verify(tenantConfigStore).removeTenant("alpha");
        order.verify(routingDataSource).removeTenantAtomically("alpha");
        order.verify(readinessRegistry).replaceDesired(any());
        order.verify(retiredPoolService).retire(oldDataSource);
    }

    @Test
    void returnsLocalOnlyReceiptWithSuccessfulRemoval() {
        TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
                false,
                false,
                null,
                List.of(oldAlpha),
                List.of(beta)
        );
        when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
        when(routingDataSource.removeTenantAtomically("alpha"))
                .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));

        TenantLifecycleMutationResult result = service.removeTenantWithReceipt("alpha");

        assertThat(result.receipt()).isSameAs(receipt);
        assertConfig(result.tenant(),
                "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
    }

    @Test
    void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
        TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
        when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
        when(routingDataSource.removeTenantAtomically("alpha"))
                .thenThrow(new IllegalStateException("тестовый отказ удаления"));

        assertThatThrownBy(() -> service.removeTenant("alpha"))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");

        verify(tenantConfigStore).compensate(receipt);
        verify(retiredPoolService, never()).retire(oldDataSource);
    }

    @Test
    void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
        TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
        when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);

        service.synchronizeFromPersistedConfig(List.of(gamma));

        InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
        order.verify(readinessRegistry).replaceDesired(any());
        order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
        order.verify(migrationService).migrate(candidate);
        order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
        order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
        order.verify(readinessRegistry).markConnectivity("gamma", true);
        order.verify(readinessRegistry).clearConfigurationFailure();
        verify(tenantConfigStore, never()).upsertTenant(any());
    }

    @ParameterizedTest
    @MethodSource("changedPersistedAlphaConfigs")
    void replacesExistingPersistedTenantWhenAnyNormalizedFieldChanges(TenantConfig changedConfig) {
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));

        service.synchronizeFromPersistedConfig(List.of(changedConfig));

        ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
        verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture());
        verify(migrationService).migrate(candidate);
        verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
        verify(retiredPoolService).retire(oldDataSource);
        verify(tenantConfigStore, never()).upsertTenant(any());
        verify(tenantConfigStore, never()).removeTenant(any());
        assertConfig(
                preparedConfig.getValue(),
                changedConfig.getName().trim(),
                "alpha",
                changedConfig.getUrl().trim(),
                changedConfig.getUsername(),
                changedConfig.getPassword()
        );
    }

    @Test
    void normalizedEquivalentPersistedTenantDoesNotRecreatePool() {
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
        TenantConfig equivalent = config(
                "  Старый Alpha  ",
                "  ALPHA  ",
                "  jdbc:old-alpha  ",
                "old-user",
                "old-password"
        );

        service.synchronizeFromPersistedConfig(List.of(equivalent));

        verify(routingDataSource, never()).prepareTenantDataSource(any());
        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(routingDataSource, never()).removeTenantAtomically(any());
        verify(migrationService, never()).migrate(any());
        verify(retiredPoolService, never()).retire(any());
        verify(readinessRegistry).clearConfigurationFailure();
    }

    @Test
    void failedPersistedReplacementKeepsOldPoolAndClosesCandidate() {
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
        org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
                .when(migrationService).migrate(candidate);

        assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(newAlpha())))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось выполнить миграции базы данных тенанта");

        verify(routingDataSource, never()).swapTenant(any(), any());
        verify(retiredPoolService, never()).retire(oldDataSource);
        verify(candidate).close();
        verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class));
        verify(readinessRegistry, never()).clearConfigurationFailure();
        verify(tenantConfigStore, never()).upsertTenant(any());
    }

    @Test
    void retryRestoresReadinessAfterPostSwapPublicationFailureWithoutSecondSwap() {
        TenantConfig changed = newAlpha();
        Map<String, TenantConfig> activatedSnapshot = new LinkedHashMap<>();
        activatedSnapshot.put("alpha", changed);
        activatedSnapshot.put("beta", beta);
        when(routingDataSource.snapshotTenantConfigs())
                .thenReturn(oldSnapshot, activatedSnapshot);
        TenantReadinessRegistry.TenantStatus interruptedStatus = new TenantReadinessRegistry.TenantStatus(
                "alpha",
                "тестовый-fingerprint",
                TenantReadinessRegistry.MigrationState.IN_PROGRESS,
                TenantReadinessRegistry.ConnectivityState.UNKNOWN,
                null,
                false,
                Instant.EPOCH
        );
        when(readinessRegistry.snapshot()).thenReturn(Map.of("alpha", interruptedStatus));
        when(routingDataSource.testConnection("alpha")).thenReturn(true);
        doThrow(new IllegalStateException("тестовый отказ публикации readiness"))
                .doNothing()
                .when(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));

        assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(changed, beta)))
                .isInstanceOf(IllegalStateException.class);

        service.synchronizeFromPersistedConfig(List.of(changed, beta));

        verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
        verify(migrationService).migrate(candidate);
        verify(retiredPoolService).retire(oldDataSource);
        verify(readinessRegistry, never()).markMigrationFailed(any(TenantConfig.class));
        verify(routingDataSource).testConnection("alpha");
        verify(readinessRegistry).markConnectivity("alpha", true);
        verify(readinessRegistry).clearConfigurationFailure();
    }

    @Test
    void appliesMergedPersistedSnapshotWithoutWritingSecret() {
        TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password");
        TenantConfig newBeta = config("Новый Beta", "beta", "jdbc:new-beta", "new-beta-user", "new-beta-password");
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of(
                "alpha", oldAlpha,
                "gamma", gamma
        ));
        when(routingDataSource.removeTenantAtomically("gamma"))
                .thenReturn(new TenantRoutingDataSource.TenantState("gamma", gamma, oldDataSource));

        service.synchronizeFromPersistedConfig(List.of(newAlpha(), newBeta));

        verify(routingDataSource, times(2)).prepareTenantDataSource(any(TenantConfig.class));
        verify(routingDataSource, times(2)).swapTenant(any(TenantConfig.class), any());
        verify(routingDataSource).removeTenantAtomically("gamma");
        verify(tenantConfigStore, never()).upsertTenant(any());
        verify(tenantConfigStore, never()).removeTenant(any());
        verify(tenantConfigStore, never()).compensate(any());
    }

    @Test
    void rejectsDuplicateNormalizedDomainsBeforeRuntimeOrReadinessMutation() {
        TenantConfig first = config("Alpha 1", "alpha", "jdbc:alpha-1", "user", "password");
        TenantConfig second = config("Alpha 2", " ALPHA ", "jdbc:alpha-2", "user", "password");

        assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(first, second)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Конфигурация содержит повторяющиеся домены тенантов");

        verify(routingDataSource, never()).snapshotTenantConfigs();
        verify(routingDataSource, never()).prepareTenantDataSource(any());
        verify(readinessRegistry, never()).replaceDesired(any());
    }

    @Test
    void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
        TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
        when(routingDataSource.hasTenant("gamma")).thenReturn(false);
        org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
                .when(migrationService).migrate(candidate);

        assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(gamma)))
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось выполнить миграции базы данных тенанта");

        verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
        verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class));
        verify(readinessRegistry, never()).markMigrationSucceeded(any(TenantConfig.class));
        verify(candidate).close();
    }

    @Test
    void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
        String maxLengthDomain = "a".repeat(63);

        TenantConfig shortest = service.addOrUpdateTenant(config(
                "Короткий", " A ", "jdbc:short", "user", "password"
        ));
        TenantConfig longest = service.addOrUpdateTenant(config(
                "Длинный", maxLengthDomain, "jdbc:long", "user", "password"
        ));

        assertThat(shortest.getDomain()).isEqualTo("a");
        assertThat(longest.getDomain()).isEqualTo(maxLengthDomain);
        assertThatThrownBy(() -> service.addOrUpdateTenant(config(
                "Слишком длинный", "a".repeat(64), "jdbc:too-long", "user", "password"
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Домен содержит недопустимые символы");
        assertThatThrownBy(() -> service.addOrUpdateTenant(config(
                "С точкой", "alpha.example", "jdbc:dotted", "user", "password"
        )))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Домен содержит недопустимые символы");
    }

    private TenantConfig newAlpha() {
        return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
    }

    private static Stream<Arguments> changedPersistedAlphaConfigs() {
        return Stream.of(
                Arguments.of(new TenantConfig(
                        "Новое имя", "alpha", "jdbc:old-alpha", "old-user", "old-password"
                )),
                Arguments.of(new TenantConfig(
                        "Старый Alpha", "alpha", "jdbc:new-alpha", "old-user", "old-password"
                )),
                Arguments.of(new TenantConfig(
                        "Старый Alpha", "alpha", "jdbc:old-alpha", "new-user", "old-password"
                )),
                Arguments.of(new TenantConfig(
                        "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "new-password"
                ))
        );
    }

    private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
        Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
        committed.put(tenant.getDomain(), tenant);
        return new TenantSecretUpdateReceipt(
                true,
                true,
                "2",
                sortedConfigs(oldSnapshot),
                sortedConfigs(committed)
        );
    }

    private TenantSecretUpdateReceipt removeReceipt(String domain) {
        Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
        committed.remove(domain);
        return new TenantSecretUpdateReceipt(
                true,
                true,
                "2",
                sortedConfigs(oldSnapshot),
                sortedConfigs(committed)
        );
    }

    private List<TenantConfig> sortedConfigs(Map<String, TenantConfig> configs) {
        List<TenantConfig> result = new ArrayList<>(configs.values());
        result.sort(Comparator.comparing(TenantConfig::getDomain));
        return result;
    }

    private TenantConfig config(String name,
                                String domain,
                                String url,
                                String username,
                                String password) {
        return new TenantConfig(name, domain, url, username, password);
    }

    private void assertConfig(TenantConfig actual,
                              String name,
                              String domain,
                              String url,
                              String username,
                              String password) {
        assertThat(actual.getName()).isEqualTo(name);
        assertThat(actual.getDomain()).isEqualTo(domain);
        assertThat(actual.getUrl()).isEqualTo(url);
        assertThat(actual.getUsername()).isEqualTo(username);
        assertThat(actual.getPassword()).isEqualTo(password);
    }

}
