package com.magistr.app.config.auth;

import com.magistr.app.config.tenant.TenantConfig;
import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantRoutingDataSource;
import com.magistr.app.repository.AuthRefreshTokenRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.*;

@DisplayName("Tenant-aware очистка refresh-сессий")
class RefreshTokenCleanupJobTest {

    private static final ZoneId ZONE = ZoneId.of("Europe/Moscow");
    private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-18T09:00:00Z"), ZONE);
    private static final Instant NOW = CLOCK.instant();
    private static final Duration RETENTION = Duration.ofDays(30);

    private ExecutorService executor;

    @AfterEach
    void cleanup() throws InterruptedException {
        TenantContext.clear();
        if (executor != null) {
            executor.shutdownNow();
            assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
        }
    }

    @Test
    @DisplayName("два pod удаляют старые строки идемпотентно и сохраняют active/fresh audit")
    void twoPodsKeepActiveAndFreshAuditRows() throws Exception {
        TenantRoutingDataSource routingDataSource = routing("alpha", "beta");
        AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
        TokenStore store = new TokenStore("alpha", "beta");
        when(repository.deleteCleanupBatch(any(Instant.class), anyInt()))
                .thenAnswer(invocation -> store.deleteBatch(
                        TenantContext.getCurrentTenant(),
                        invocation.getArgument(0),
                        invocation.getArgument(1)
                ));
        RefreshTokenCleanupJob firstPod = job(routingDataSource, repository, true);
        RefreshTokenCleanupJob secondPod = job(routingDataSource, repository, true);

        executor = Executors.newFixedThreadPool(2);
        CountDownLatch start = new CountDownLatch(1);
        Future<RefreshTokenCleanupJob.CleanupSummary> first = executor.submit(() -> {
            start.await(5, TimeUnit.SECONDS);
            return firstPod.cleanupNow();
        });
        Future<RefreshTokenCleanupJob.CleanupSummary> second = executor.submit(() -> {
            start.await(5, TimeUnit.SECONDS);
            return secondPod.cleanupNow();
        });
        start.countDown();

        var firstSummary = first.get(5, TimeUnit.SECONDS);
        var secondSummary = second.get(5, TimeUnit.SECONDS);

        assertThat(firstSummary.deletedCount() + secondSummary.deletedCount()).isEqualTo(4);
        assertThat(firstSummary.failedTenantCount() + secondSummary.failedTenantCount()).isZero();
        assertThat(store.labels("alpha")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");
        assertThat(store.labels("beta")).containsExactlyInAnyOrder("fresh-expired", "fresh-revoked", "active");

        var repeated = firstPod.cleanupNow();
        assertThat(repeated.deletedCount()).isZero();
        assertThat(store.labels("alpha")).hasSize(3);
        assertThat(store.labels("beta")).hasSize(3);
    }

    @Test
    @DisplayName("повторный локальный запуск пропускается, пока текущий проход не завершён")
    void overlappingRunInsidePodIsSkipped() throws Exception {
        TenantRoutingDataSource routingDataSource = routing("alpha");
        AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
        CountDownLatch enteredRepository = new CountDownLatch(1);
        CountDownLatch releaseRepository = new CountDownLatch(1);
        when(repository.deleteCleanupBatch(any(Instant.class), anyInt())).thenAnswer(invocation -> {
            enteredRepository.countDown();
            if (!releaseRepository.await(5, TimeUnit.SECONDS)) {
                throw new IllegalStateException("Тест не разрешил завершить очистку");
            }
            return 0;
        });
        RefreshTokenCleanupJob job = job(routingDataSource, repository, true);
        executor = Executors.newSingleThreadExecutor();
        Future<RefreshTokenCleanupJob.CleanupSummary> running = executor.submit(job::cleanupNow);
        assertThat(enteredRepository.await(5, TimeUnit.SECONDS)).isTrue();

        var overlapping = job.cleanupNow();

        assertThat(overlapping.skippedBecauseAlreadyRunning()).isTrue();
        releaseRepository.countDown();
        assertThat(running.get(5, TimeUnit.SECONDS).skippedBecauseAlreadyRunning()).isFalse();
    }

    @Test
    @DisplayName("отключённое расписание не обращается к tenant-БД")
    void disabledScheduleDoesNothing() {
        TenantRoutingDataSource routingDataSource = routing("alpha");
        AuthRefreshTokenRepository repository = mock(AuthRefreshTokenRepository.class);
        RefreshTokenCleanupJob job = job(routingDataSource, repository, false);

        job.cleanupScheduled();

        verifyNoInteractions(repository);
    }

    private RefreshTokenCleanupJob job(TenantRoutingDataSource routingDataSource,
                                       AuthRefreshTokenRepository repository,
                                       boolean enabled) {
        return new RefreshTokenCleanupJob(
                routingDataSource,
                repository,
                enabled,
                RETENTION,
                2,
                10,
                CLOCK
        );
    }

    private TenantRoutingDataSource routing(String... tenants) {
        TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
        Map<String, TenantConfig> configs = new LinkedHashMap<>();
        for (String tenant : tenants) {
            configs.put(tenant, new TenantConfig(tenant, tenant, "jdbc:test:" + tenant, "user", "password"));
        }
        when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.copyOf(configs));
        return routingDataSource;
    }

    private static final class TokenStore {

        private final Map<String, List<TokenRow>> rowsByTenant = new LinkedHashMap<>();

        private TokenStore(String... tenants) {
            for (String tenant : tenants) {
                rowsByTenant.put(tenant, new ArrayList<>(List.of(
                        new TokenRow("old-expired", minusDays(NOW, 31), null),
                        new TokenRow("old-revoked", plusDays(NOW, 7), minusDays(NOW, 31)),
                        new TokenRow("fresh-expired", minusDays(NOW, 1), null),
                        new TokenRow("fresh-revoked", plusDays(NOW, 7), minusDays(NOW, 1)),
                        new TokenRow("active", plusDays(NOW, 7), null)
                )));
            }
        }

        private synchronized int deleteBatch(String tenant, Instant cutoff, int batchSize) {
            assertThat(tenant).isNotBlank();
            List<TokenRow> rows = rowsByTenant.get(tenant);
            List<TokenRow> candidates = rows.stream()
                    .filter(row -> row.expiresAt().isBefore(cutoff)
                            || row.revokedAt() != null && row.revokedAt().isBefore(cutoff))
                    .limit(batchSize)
                    .toList();
            rows.removeAll(candidates);
            return candidates.size();
        }

        private synchronized List<String> labels(String tenant) {
            return rowsByTenant.get(tenant).stream().map(TokenRow::label).toList();
        }
    }

    private static Instant minusDays(Instant value, long days) {
        return value.minus(days, java.time.temporal.ChronoUnit.DAYS);
    }

    private static Instant plusDays(Instant value, long days) {
        return value.plus(days, java.time.temporal.ChronoUnit.DAYS);
    }

    private record TokenRow(String label, Instant expiresAt, Instant revokedAt) {
    }
}
