package dev.awesomedev.mirim.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 이 파일이 하는 일:
 * 로그인 실패를 세다가, 너무 많이 틀리면 잠시 잠근다 — 무차별 대입(brute force) 방어.
 *
 * 규칙: 같은 (아이디+IP)에서 5회 연속 실패하면 5분간 로그인 시도를 막는다.
 *
 * 학습 포인트 ① — 왜 "아이디+IP"를 묶어서 셀까?
 *   아이디로만 세면: 공격자가 일부러 mentor1을 5번 틀려서 멘토를 잠가버릴 수 있다(잠금 악용 DoS).
 *   IP로만 세면: 학교/회사처럼 여럿이 한 IP를 쓰는 곳에서 한 명의 실수로 모두가 잠긴다.
 *   둘을 묶으면: 공격자는 막히고, 진짜 사용자는 자기 자리에서 멀쩡히 로그인할 수 있다.
 *
 * 학습 포인트 ② — 왜 DB가 아니라 메모리(Map)에 저장할까?
 *   이 기록은 (1) 5분이면 쓸모없어지고 (2) 유실돼도 피해가 없다(다시 세면 그만).
 *   백엔드가 1대뿐인 지금 규모에선 메모리가 가장 단순하고 빠르다.
 *   서버가 여러 대가 되면 그때 Redis 같은 공유 저장소로 옮긴다 — 규모가 설계를 정한다.
 *
 * 학습 포인트 ③ — 시간을 Clock으로 주입받는 이유.
 *   Instant.now()를 직접 쓰면 "5분 지나면 풀린다"를 테스트하려고 5분을 기다려야 한다.
 *   Clock을 주입받으면 테스트에서 가짜 시계를 넣어 시간을 마음대로 돌릴 수 있다.
 *   "테스트하기 어려운 것(시간, 랜덤, 외부 API)은 밖에서 주입받는다" — 테스트 설계의 기본기.
 */
@Service
public class LoginAttemptService {

    private static final Logger log = LoggerFactory.getLogger(LoginAttemptService.class);

    /** 몇 번 틀리면 잠글까 */
    static final int MAX_FAILURES = 5;
    /** 얼마나 잠글까 */
    static final Duration LOCK_DURATION = Duration.ofMinutes(5);
    /** 마지막 실패 후 이 시간이 지나면 실패 횟수를 처음부터 다시 센다 */
    static final Duration FAILURE_WINDOW = Duration.ofMinutes(5);

    private final Clock clock;
    // ConcurrentHashMap: 여러 요청(스레드)이 동시에 만져도 안전한 Map.
    private final Map<String, Attempt> attempts = new ConcurrentHashMap<>();

    /** 실패 기록 한 건: 몇 번 틀렸고, 마지막으로 언제 틀렸나 */
    private record Attempt(int failures, Instant lastFailureAt) {
    }

    public LoginAttemptService(Clock clock) {
        this.clock = clock;
    }

    /** 이 (아이디+IP)가 지금 잠겨 있는가? 로그인 검증 "전에" 먼저 물어봐야 한다. */
    public boolean isLocked(String username, String ip) {
        Attempt attempt = attempts.get(key(username, ip));
        if (attempt == null || attempt.failures() < MAX_FAILURES) {
            return false;
        }
        // 잠금 시간이 지났으면 기록을 지우고 통과시킨다.
        if (expired(attempt)) {
            attempts.remove(key(username, ip));
            return false;
        }
        return true;
    }

    /** 로그인 실패를 기록한다. 한도에 도달하면 이후 isLocked()가 true가 된다. */
    public void recordFailure(String username, String ip) {
        Instant now = clock.instant();
        attempts.compute(key(username, ip), (k, prev) -> {
            // 오래된 실패 기록은 이어서 세지 않고 1부터 다시 센다.
            int count = (prev == null || expired(prev)) ? 1 : prev.failures() + 1;
            if (count == MAX_FAILURES) {
                // 학습 포인트: 잠금은 보안 "사건"이다 — 로그를 남겨야 나중에 공격 여부를 추적할 수 있다.
                // 단, 비밀번호는 절대 로그에 남기지 않는다.
                log.warn("로그인 {}회 연속 실패로 잠금: username={}, ip={}", MAX_FAILURES, username, ip);
            }
            return new Attempt(count, now);
        });
        purgeIfLarge();
    }

    /** 로그인에 성공하면 실패 기록을 지운다 — 어제 두 번 틀린 것까지 기억할 필요는 없다. */
    public void reset(String username, String ip) {
        attempts.remove(key(username, ip));
    }

    private String key(String username, String ip) {
        // 대소문자만 바꿔 다시 시도하는 우회를 막기 위해 아이디는 소문자로 통일한다.
        return username.toLowerCase() + "|" + ip;
    }

    private boolean expired(Attempt attempt) {
        Duration limit = attempt.failures() >= MAX_FAILURES ? LOCK_DURATION : FAILURE_WINDOW;
        return clock.instant().isAfter(attempt.lastFailureAt().plus(limit));
    }

    /**
     * 학습 포인트: 메모리에 쌓기만 하는 자료구조는 언젠가 메모리를 다 먹는다(누수).
     * 공격자가 아이디를 바꿔가며 수만 번 시도하면 Map이 그만큼 커지므로,
     * 일정 크기를 넘으면 만료된 기록을 청소한다. (우리 규모에선 사실상 안 돌지만, 습관이다.)
     */
    private void purgeIfLarge() {
        if (attempts.size() > 10_000) {
            attempts.entrySet().removeIf(e -> expired(e.getValue()));
        }
    }
}
