package com.magistr.app.config.auth;

import com.magistr.app.model.AuthLoginAttemptAudit;
import com.magistr.app.model.AuthLoginRateLimit;
import com.magistr.app.model.User;
import com.magistr.app.repository.AuthLoginAttemptAuditRepository;
import com.magistr.app.repository.AuthLoginRateLimitRepository;
import com.magistr.app.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.Normalizer;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;

@Service
public class LoginRateLimitService {

    private static final Logger log = LoggerFactory.getLogger(LoginRateLimitService.class);
    private static final String OUTCOME_FAILURE = "FAILURE";
    private static final String OUTCOME_BLOCKED = "BLOCKED";

    private final AuthLoginRateLimitRepository rateLimitRepository;
    private final AuthLoginAttemptAuditRepository auditRepository;
    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;
    private final LoginSecurityProperties properties;
    private final Clock clock;
    private final String dummyPasswordHash;

    @Autowired
    public LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
                                 AuthLoginAttemptAuditRepository auditRepository,
                                 UserRepository userRepository,
                                 BCryptPasswordEncoder passwordEncoder,
                                 LoginSecurityProperties properties) {
        this(rateLimitRepository, auditRepository, userRepository, passwordEncoder,
                properties, Clock.systemUTC());
    }

    LoginRateLimitService(AuthLoginRateLimitRepository rateLimitRepository,
                          AuthLoginAttemptAuditRepository auditRepository,
                          UserRepository userRepository,
                          BCryptPasswordEncoder passwordEncoder,
                          LoginSecurityProperties properties,
                          Clock clock) {
        this.rateLimitRepository = Objects.requireNonNull(rateLimitRepository);
        this.auditRepository = Objects.requireNonNull(auditRepository);
        this.userRepository = Objects.requireNonNull(userRepository);
        this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
        this.properties = Objects.requireNonNull(properties);
        this.clock = Objects.requireNonNull(clock);
        this.dummyPasswordHash = passwordEncoder.encode("dummy-password-for-timing-only");
    }

    /**
     * Блокировка строки удерживается до завершения проверки пароля и записи результата.
     * Поэтому разные backend-pod не могут одновременно обойти общий счётчик попыток.
     */
    @Transactional
    public AuthenticationDecision authenticate(String tenant,
                                                 String username,
                                                 String rawPassword,
                                                 String clientIp) {
        String safeTenant = normalizeAndLimit(tenant, 100, "unknown-tenant");
        String normalizedUsername = normalizeAndLimit(username, 100, "пустое-имя");
        String safeClientIp = normalizeAndLimit(clientIp, 64, "неизвестен");
        Instant now = clock.instant();

        rateLimitRepository.ensureExists(safeTenant, normalizedUsername, safeClientIp, now);
        AuthLoginRateLimit state = rateLimitRepository
                .findForUpdate(safeTenant, normalizedUsername, safeClientIp)
                .orElseThrow(() -> new IllegalStateException("Не удалось заблокировать счётчик попыток входа"));

        if (state.getBlockedUntil() != null && state.getBlockedUntil().isAfter(now)) {
            long retryAfter = retryAfterSeconds(now, state.getBlockedUntil());
            audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
            logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
            return AuthenticationDecision.blocked(retryAfter);
        }

        if (!state.getWindowStartedAt().plus(properties.getAttemptWindow()).isAfter(now)) {
            state.setFailureCount(0);
            state.setWindowStartedAt(now);
            state.setBlockedUntil(null);
        }

        Optional<User> user = username == null ? Optional.empty() : userRepository.findByUsername(username);
        String storedHash = user.map(User::getPassword).orElse(dummyPasswordHash);
        boolean passwordMatches = passwordEncoder.matches(
                rawPassword == null ? "" : rawPassword,
                storedHash
        );
        boolean authenticated = passwordMatches && user.isPresent() && !user.get().isArchivedRecord();

        if (authenticated) {
            rateLimitRepository.delete(state);
            return AuthenticationDecision.authenticated(user.get());
        }

        int failureCount = state.getFailureCount() + 1;
        state.setFailureCount(failureCount);
        state.setLastFailureAt(now);
        state.setUpdatedAt(now);

        if (failureCount >= properties.getMaxFailures()) {
            Duration blockDuration = progressiveBlockDuration(failureCount);
            Instant blockedUntil = now.plus(blockDuration);
            state.setBlockedUntil(blockedUntil);
            long retryAfter = retryAfterSeconds(now, blockedUntil);
            audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, now, retryAfter);
            logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_BLOCKED, retryAfter);
            return AuthenticationDecision.blocked(retryAfter);
        }

        audit(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, now, null);
        logDenied(safeTenant, normalizedUsername, safeClientIp, OUTCOME_FAILURE, null);
        return AuthenticationDecision.rejected();
    }

    private Duration progressiveBlockDuration(int failureCount) {
        int exponent = Math.min(failureCount - properties.getMaxFailures(), 30);
        long multiplier = 1L << exponent;
        Duration candidate;
        try {
            candidate = properties.getBaseBlockDuration().multipliedBy(multiplier);
        } catch (ArithmeticException overflow) {
            return properties.getMaxBlockDuration();
        }
        return candidate.compareTo(properties.getMaxBlockDuration()) > 0
                ? properties.getMaxBlockDuration()
                : candidate;
    }

    private void audit(String tenant,
                       String username,
                       String clientIp,
                       String outcome,
                       Instant occurredAt,
                       Long retryAfterSeconds) {
        Integer retryAfter = retryAfterSeconds == null
                ? null
                : Math.toIntExact(Math.min(retryAfterSeconds, Integer.MAX_VALUE));
        auditRepository.save(new AuthLoginAttemptAudit(
                tenant, username, clientIp, outcome, occurredAt, retryAfter
        ));
    }

    private void logDenied(String tenant,
                           String username,
                           String clientIp,
                           String outcome,
                           Long retryAfterSeconds) {
        log.warn(
                "Неудачная попытка входа: tenant={}, usernameFingerprint={}, clientIp={}, outcome={}, retryAfterSeconds={}",
                tenant,
                fingerprint(username),
                clientIp,
                outcome,
                retryAfterSeconds
        );
    }

    private String normalizeAndLimit(String value, int maxLength, String fallback) {
        String bounded = value == null
                ? fallback
                : value.substring(0, Math.min(value.length(), maxLength * 4));
        String normalized = Normalizer.normalize(bounded, Normalizer.Form.NFKC)
                .replaceAll("\\p{C}", "")
                .strip()
                .toLowerCase(Locale.ROOT);
        if (normalized.isBlank()) {
            normalized = fallback;
        }
        return normalized.length() <= maxLength ? normalized : normalized.substring(0, maxLength);
    }

    private long retryAfterSeconds(Instant now, Instant blockedUntil) {
        Duration remaining = Duration.between(now, blockedUntil);
        long seconds = remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0);
        return Math.max(1, seconds);
    }

    private String fingerprint(String value) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256")
                    .digest(value.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest, 0, 8);
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("В JVM недоступен алгоритм SHA-256", exception);
        }
    }

    public record AuthenticationDecision(Status status, User user, long retryAfterSeconds) {

        static AuthenticationDecision authenticated(User user) {
            return new AuthenticationDecision(Status.AUTHENTICATED, user, 0);
        }

        static AuthenticationDecision rejected() {
            return new AuthenticationDecision(Status.REJECTED, null, 0);
        }

        static AuthenticationDecision blocked(long retryAfterSeconds) {
            return new AuthenticationDecision(Status.BLOCKED, null, retryAfterSeconds);
        }

        public enum Status {
            AUTHENTICATED,
            REJECTED,
            BLOCKED
        }
    }
}
