package com.magistr.app.config.tenant;

import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantLifecycleException;
import com.magistr.app.service.TenantLifecycleService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.test.util.ReflectionTestUtils;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.LongSupplier;

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.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

@ExtendWith(OutputCaptureExtension.class)
class TenantConfigWatcherTest {

    @TempDir
    Path tempDirectory;

    @Test
    void configHashUsesSha256Hex() {
        String hash = TenantConfigWatcher.configHash("{\"domain\":\"localhost\"}");

        assertThat(hash).hasSize(64);
        assertThat(hash).matches("[0-9a-f]{64}");
        assertThat(hash).isNotEqualTo(Integer.toHexString("{\"domain\":\"localhost\"}".hashCode()));
    }

    @Test
    void configHashChangesWhenContentChanges() {
        assertThat(TenantConfigWatcher.configHash("tenant-a"))
                .isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
    }

    @Test
    void prepareForApiMutationSynchronizesUnappliedBaselineOnce() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String content = json("alpha");
        Files.writeString(configPath, content);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);

        watcher.prepareForApiMutation();
        watcher.prepareForApiMutation();

        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash(content));
        assertThat(ReflectionTestUtils.getField(watcher, "lastAppliedSnapshot")).isNotNull();
    }

    @Test
    void prepareForApiMutationPropagatesReadFailure() throws Exception {
        Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
        TenantConfigWatcher watcher = watcher(
                lifecycleService,
                readinessRegistry,
                configPath,
                new AtomicLong(1_000L)
        );

        assertThatThrownBy(watcher::prepareForApiMutation)
                .isInstanceOf(TenantLifecycleException.class)
                .hasMessage("Не удалось подготовить актуальную конфигурацию тенантов перед изменением");

        verify(readinessRegistry).markConfigurationFailure();
        verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(true);
    }

    @Test
    void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String content = json("alpha");
        Files.writeString(configPath, content);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
                .doNothing()
                .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
        AtomicLong now = new AtomicLong(1_000L);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);

        watcher.watchForChanges();
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");

        now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
        watcher.watchForChanges();

        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash(content));
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());

        watcher.watchForChanges();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
    }

    @Test
    void retriesSameRevisionOnlyAfterBackoffAndResetsStateAfterSuccess() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String content = "[]";
        Files.writeString(configPath, content);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        doThrow(new IllegalStateException("первая тестовая ошибка"))
                .doThrow(new IllegalStateException("вторая тестовая ошибка"))
                .doNothing()
                .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
        AtomicLong now = new AtomicLong(10_000L);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);

        watcher.watchForChanges();
        watcher.watchForChanges();
        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());

        now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
        watcher.watchForChanges();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());

        now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS * 2 - 1);
        watcher.watchForChanges();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());

        now.incrementAndGet();
        watcher.watchForChanges();

        verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(0);
        assertThat(ReflectionTestUtils.getField(watcher, "retryNotBeforeMillis")).isEqualTo(0L);
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash(content));
    }

    @Test
    void retryDelayGrowsExponentiallyAndIsCapped() {
        assertThat(TenantConfigWatcher.retryDelayMillis(1)).isEqualTo(30_000L);
        assertThat(TenantConfigWatcher.retryDelayMillis(2)).isEqualTo(60_000L);
        assertThat(TenantConfigWatcher.retryDelayMillis(3)).isEqualTo(120_000L);
        assertThat(TenantConfigWatcher.retryDelayMillis(4)).isEqualTo(240_000L);
        assertThat(TenantConfigWatcher.retryDelayMillis(5)).isEqualTo(300_000L);
        assertThat(TenantConfigWatcher.retryDelayMillis(31)).isEqualTo(300_000L);
    }

    @Test
    void newRevisionBypassesBackoffImmediately() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, "[]");
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        doThrow(new IllegalStateException("тестовая ошибка"))
                .doNothing()
                .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));

        watcher.watchForChanges();
        Files.writeString(configPath, "[ ]");
        watcher.watchForChanges();

        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash("[ ]"));
    }

    @Test
    void restoredLastAppliedRevisionIsReconciledAfterFailedChange() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String firstRevision = "[]";
        String failedRevision = "[ ]";
        Files.writeString(configPath, firstRevision);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        doNothing()
                .doThrow(new IllegalStateException("тестовая ошибка новой ревизии"))
                .doNothing()
                .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));

        watcher.watchForChanges();
        Files.writeString(configPath, failedRevision);
        watcher.watchForChanges();
        Files.writeString(configPath, firstRevision);
        watcher.watchForChanges();

        verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash(firstRevision));
        assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(false);
    }

    @Test
    void semanticFenceDefersStaleProjectionWithDifferentFormatting() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String baseline = json("alpha");
        Files.writeString(configPath, baseline);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();
        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));

        Files.writeString(configPath, prettyJson("alpha"));
        watcher.watchForChanges();

        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
                .isEqualTo(TenantConfigWatcher.configHash(baseline));
        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNotNull();
    }

    @Test
    void prepareForNextMutationDoesNotApplyKnownStaleProjection() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, json("alpha"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();
        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));

        watcher.prepareForApiMutation();

        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
    }

    @Test
    void nonPersistedReceiptDoesNotArmFence() {
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(
                lifecycleService,
                tempDirectory.resolve("tenants.json")
        );

        watcher.registerSuccessfulMutation(receipt(
                false,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));

        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
        assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
    }

    @Test
    void persistedNoOpDoesNotSuppressMergedProjection() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, json("alpha"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();
        watcher.registerSuccessfulMutation(receipt(
                true,
                false,
                tenants("alpha"),
                tenants("alpha")
        ));

        Files.writeString(configPath, json("alpha", "gamma"));
        watcher.watchForChanges();

        ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
        assertThat(captor.getAllValues().get(1))
                .extracting(TenantConfig::getDomain)
                .containsExactly("alpha", "gamma");
        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
    }

    @Test
    void twoRapidMutationsDeferIntermediateProjectionUntilLatestCommit() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, json("alpha"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();

        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));
        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha", "beta"),
                tenants("alpha", "beta", "gamma")
        ));

        Files.writeString(configPath, json("alpha", "beta"));
        watcher.watchForChanges();
        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());

        Files.writeString(configPath, json("alpha", "beta", "gamma"));
        watcher.watchForChanges();

        ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
        assertThat(captor.getAllValues().get(1))
                .extracting(TenantConfig::getDomain)
                .containsExactly("alpha", "beta", "gamma");
        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
        assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
    }

    @Test
    void cyclicRapidMutationsClearFenceWhenLatestCommitIsAlreadyApplied() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, json("alpha"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();

        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));
        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha", "beta"),
                tenants("alpha")
        ));

        watcher.watchForChanges();

        verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
        assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
    }

    @Test
    void unknownConcurrentSnapshotSupersedesExpectedAndIsApplied() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, json("alpha"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
        watcher.prepareForApiMutation();
        watcher.registerSuccessfulMutation(receipt(
                true,
                true,
                tenants("alpha"),
                tenants("alpha", "beta")
        ));

        Files.writeString(configPath, json("alpha", "gamma"));
        watcher.watchForChanges();

        ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
        verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
        assertThat(captor.getAllValues().get(1))
                .extracting(TenantConfig::getDomain)
                .containsExactly("alpha", "gamma");
        assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
        assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
    }

    @Test
    void requiredMissingFileUsesBackoff() {
        Path configPath = tempDirectory.resolve("tenants.json");
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
        AtomicLong now = new AtomicLong(1_000L);
        TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now);
        ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true);

        watcher.watchForChanges();
        watcher.watchForChanges();

        verify(readinessRegistry).markConfigurationFailure();
        assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1);

        now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
        watcher.watchForChanges();

        verify(readinessRegistry, times(2)).markConfigurationFailure();
        assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2);
    }

    @Test
    void readFailureUsesBackoff() throws Exception {
        Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json"));
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
        AtomicLong now = new AtomicLong(1_000L);
        TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now);

        watcher.watchForChanges();
        watcher.watchForChanges();

        verify(readinessRegistry).markConfigurationFailure();
        assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1);

        now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
        watcher.watchForChanges();

        verify(readinessRegistry, times(2)).markConfigurationFailure();
        assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2);
    }

    @Test
    void requiredEmptyConfigMarksFailureAndKeepsHashUnapplied() throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        Files.writeString(configPath, "[]");
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
        TenantConfigWatcher watcher = watcher(
                lifecycleService,
                readinessRegistry,
                configPath,
                new AtomicLong(1_000L)
        );
        ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true);

        watcher.watchForChanges();

        verify(readinessRegistry).markConfigurationFailure();
        verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList());
        assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
    }

    @Test
    void failureLogsRetryMetadataWithoutSecretsOrFingerprints(CapturedOutput output) throws Exception {
        Path configPath = tempDirectory.resolve("tenants.json");
        String content = """
                [{"domain":"alpha","url":"jdbc:postgresql://secret-host/db",
                "username":"secret-user","password":"secret-password"}]
                """;
        Files.writeString(configPath, content);
        TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
        doThrow(new IllegalStateException("секретные технические детали"))
                .when(lifecycleService).synchronizeFromPersistedConfig(anyList());
        TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));

        watcher.watchForChanges();

        assertThat(output)
                .contains("Ошибка синхронизации конфигурации тенантов")
                .contains("попытка=1")
                .contains("через 30000 мс")
                .doesNotContain("secret-host")
                .doesNotContain("secret-user")
                .doesNotContain("secret-password")
                .doesNotContain(TenantConfigWatcher.configHash(content))
                .doesNotContain("revision=")
                .doesNotContain("fingerprint")
                .doesNotContain("хеш:");
    }

    private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) {
        return watcher(lifecycleService, configPath, new AtomicLong(1_000L));
    }

    private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService,
                                        Path configPath,
                                        AtomicLong now) {
        return watcher(lifecycleService, mock(TenantReadinessRegistry.class), configPath, now);
    }

    private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService,
                                        TenantReadinessRegistry readinessRegistry,
                                        Path configPath,
                                        AtomicLong now) {
        doAnswer(invocation -> {
            invocation.<Runnable>getArgument(0).run();
            return null;
        }).when(lifecycleService).executeSerialized(any(Runnable.class));
        TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService, readinessRegistry);
        ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
        ReflectionTestUtils.setField(watcher, "currentTimeMillis", (LongSupplier) now::get);
        return watcher;
    }

    private TenantSecretUpdateReceipt receipt(boolean persisted,
                                              boolean changed,
                                              List<TenantConfig> previous,
                                              List<TenantConfig> committed) {
        return new TenantSecretUpdateReceipt(
                persisted,
                changed,
                persisted ? "resource-version" : null,
                previous,
                committed
        );
    }

    private List<TenantConfig> tenants(String... domains) {
        return java.util.Arrays.stream(domains).map(this::tenant).toList();
    }

    private TenantConfig tenant(String domain) {
        return new TenantConfig(
                domain,
                domain,
                "jdbc:postgresql://db/" + domain,
                domain,
                "password-" + domain
        );
    }

    private String json(String... domains) {
        return "[" + java.util.Arrays.stream(domains)
                .map(domain -> "{\"name\":\"" + domain
                        + "\",\"domain\":\"" + domain
                        + "\",\"url\":\"jdbc:postgresql://db/" + domain
                        + "\",\"username\":\"" + domain
                        + "\",\"password\":\"password-" + domain + "\"}")
                .collect(java.util.stream.Collectors.joining(",")) + "]";
    }

    private String prettyJson(String domain) {
        return """
                [
                  {
                    "password": "password-%1$s",
                    "username": "%1$s",
                    "url": "jdbc:postgresql://db/%1$s",
                    "domain": "%1$s",
                    "name": "%1$s"
                  }
                ]
                """.formatted(domain);
    }

    @SuppressWarnings({"unchecked", "rawtypes"})
    private ArgumentCaptor<List<TenantConfig>> tenantListCaptor() {
        return (ArgumentCaptor) ArgumentCaptor.forClass(List.class);
    }
}
