package io.github.shirainbown.sharelink.emailcode;

import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicInteger;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import io.github.shirainbown.sharelink.SharelinkSettingConfigGetter;

/**
 * In-memory verification code store: one active code per email, one-time use, resend /
 * daily / ip-hourly rate limits, and a constant-time comparison.
 */
@Component
@RequiredArgsConstructor
public class EmailCodeManagerImpl implements EmailCodeManager {
    private static final SecureRandom RANDOM = new SecureRandom();
    private static final int CODE_BOUND = 1_000_000;
    /**
     * The real expiration is checked against the config on verification, this TTL is only
     * a fallback to evict stale cache entries.
     */
    private static final Duration CODE_CACHE_TTL = Duration.ofHours(1);
    private static final Duration RESEND_CACHE_TTL = Duration.ofMinutes(10);
    private static final Duration DAILY_LIMIT_TTL = Duration.ofDays(1);
    private static final Duration IP_HOURLY_LIMIT_TTL = Duration.ofHours(1);

    private final Cache<String, EmailCodeEntry> codeCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(CODE_CACHE_TTL)
            .maximumSize(10_000)
            .build();

    private final Cache<String, Instant> resendCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(RESEND_CACHE_TTL)
            .maximumSize(10_000)
            .build();

    private final Cache<String, AtomicInteger> dailySendCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(DAILY_LIMIT_TTL)
            .maximumSize(10_000)
            .build();

    private final Cache<String, AtomicInteger> ipHourlySendCache =
        CacheBuilder.newBuilder()
            .expireAfterWrite(IP_HOURLY_LIMIT_TTL)
            .maximumSize(10_000)
            .build();

    private final EmailCodeNotificationSender notificationSender;

    @Override
    public Mono<Void> sendCode(String email, String clientIp,
                               SharelinkSettingConfigGetter.EmailVerifyConfig config) {
        return sendCode(email, clientIp, config, EmailCodeNotificationSender.REASON_TYPE);
    }

    @Override
    public Mono<Void> sendCode(String email, String clientIp,
                               SharelinkSettingConfigGetter.EmailVerifyConfig config,
                               String reasonType) {
        return Mono.defer(() -> {
            checkRateLimits(email, clientIp, config);
            var code = generateCode();
            return notificationSender.sendVerificationCode(reasonType, email, email, code,
                    config.getCodeExpireMinutes())
                .subscribeOn(Schedulers.boundedElastic())
                // Record the code and rate limits only after the notification is emitted,
                // so failures neither count against the quota nor overwrite the old code.
                .doOnSuccess(sent -> recordSent(email, clientIp, code));
        });
    }

    private void checkRateLimits(String email, String clientIp,
                                 SharelinkSettingConfigGetter.EmailVerifyConfig config) {
        var lastSentAt = resendCache.getIfPresent(email);
        if (lastSentAt != null
            && Instant.now().isBefore(lastSentAt.plusSeconds(config.getResendIntervalSeconds()))) {
            throw new SendRateLimitedException(SendRateLimitedException.Type.RESEND_TOO_FREQUENT);
        }
        var dailyCounter = dailySendCache.getIfPresent(email);
        if (dailyCounter != null && dailyCounter.get() >= config.getDailySendLimitPerEmail()) {
            throw new SendRateLimitedException(
                SendRateLimitedException.Type.DAILY_LIMIT_EXCEEDED);
        }
        if (StringUtils.isNotBlank(clientIp)) {
            var ipCounter = ipHourlySendCache.getIfPresent(clientIp);
            if (ipCounter != null && ipCounter.get() >= config.getIpHourlySendLimit()) {
                throw new SendRateLimitedException(
                    SendRateLimitedException.Type.IP_HOURLY_LIMIT_EXCEEDED);
            }
        }
    }

    private void recordSent(String email, String clientIp, String code) {
        // Resending overwrites the previous code.
        codeCache.put(email, new EmailCodeEntry(code, Instant.now(), new AtomicInteger()));
        resendCache.put(email, Instant.now());
        incrementCounter(dailySendCache, email);
        if (StringUtils.isNotBlank(clientIp)) {
            incrementCounter(ipHourlySendCache, clientIp);
        }
    }

    private static void incrementCounter(Cache<String, AtomicInteger> cache, String key) {
        var counter = cache.getIfPresent(key);
        if (counter == null) {
            counter = new AtomicInteger();
            cache.put(key, counter);
        }
        counter.incrementAndGet();
    }

    @Override
    public Mono<Boolean> verify(String email, String code,
                                SharelinkSettingConfigGetter.EmailVerifyConfig config) {
        return Mono.fromSupplier(() -> {
            var entry = codeCache.getIfPresent(email);
            if (entry == null) {
                return false;
            }
            if (entry.attempts().incrementAndGet() > config.getMaxVerifyAttempts()) {
                codeCache.invalidate(email);
                return false;
            }
            if (Instant.now()
                .isAfter(entry.createdAt().plusSeconds(config.getCodeExpireMinutes() * 60L))) {
                codeCache.invalidate(email);
                return false;
            }
            var matched = MessageDigest.isEqual(entry.code().getBytes(),
                code.getBytes());
            if (matched) {
                // One-time use.
                codeCache.invalidate(email);
            }
            return matched;
        });
    }

    @Override
    public Mono<Void> invalidate(String email) {
        codeCache.invalidate(email);
        return Mono.empty();
    }

    private static String generateCode() {
        return String.format(Locale.ROOT, "%06d", RANDOM.nextInt(CODE_BOUND));
    }

    record EmailCodeEntry(String code, Instant createdAt, AtomicInteger attempts) {
    }
}
