package dev.awesomedev.mirim.service;

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;

/**
 * 이 파일이 하는 일:
 * "같은 키(보통 IP)로 정해진 시간 안에 몇 번까지만 허용"하는 범용 레이트리밋(호출 빈도 제한).
 * 무인증으로 열린 쓰기 API(회원가입·아이디찾기)를 자동화 남용으로부터 지킨다.
 *
 * 학습 포인트 ① — 왜 로그인 잠금(LoginAttemptService)과 따로 두나?
 *   로그인 잠금은 "연속 실패"를 세어 잠그는 특화 로직이다(성공하면 리셋).
 *   여기 레이트리밋은 성공/실패와 무관하게 "호출 횟수 자체"를 센다 —
 *   가입 봇이 계정을 5천 개 만드는 걸 막는 건 실패 카운트가 아니라 호출 카운트다.
 *   두 문제는 규칙이 달라 클래스를 나눈다(단일 책임).
 *
 * 학습 포인트 ② — "고정 창(fixed window)" 방식.
 *   키마다 "창이 언제 끝나는지(expiresAt)"와 "그 창에서 몇 번 왔는지(count)"만 기억한다.
 *   창이 끝나면 다음 호출에서 카운트를 0부터 새로 센다. 구현이 단순하고 이 규모에 충분하다.
 *   (정밀한 슬라이딩 윈도/토큰버킷은 더 매끄럽지만 복잡하다 — 규모가 필요를 정한다.)
 *   ⚠️ 알려진 한계: 창 경계에서는 실효 한도가 약 2배가 될 수 있다. 창 종료 직전 limit건 +
 *   만료 직후 limit건이 짧은 구간에 몰리면 한 창 기준 최대 2×limit이 통과한다(유계이므로 방어가
 *   무력화되진 않는다). 한도를 정할 땐 "경계에서 최대 2배"를 감안해 여유 있게 잡는다.
 *
 * 학습 포인트 ③ — 왜 메모리(Map)인가? Clock 주입은 왜?
 *   기록은 창이 지나면 쓸모없어지고 유실돼도 피해가 없다. 서버 1대면 메모리가 가장 단순·빠르다
 *   (여러 대가 되면 Redis 등 공유 저장소로). 시간을 Clock으로 주입받아 테스트에서 창 만료를
 *   실제로 기다리지 않고 검증한다 — LoginAttemptService와 같은 설계 원칙.
 */
@Service
public class RateLimiterService {

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

    /** 한 키의 현재 창: 이 시각에 창이 끝나고, 지금까지 몇 번 왔나. */
    private record Window(Instant expiresAt, int count) {
    }

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

    /**
     * bucket(용도 구분)+key(보통 IP) 기준으로, window 동안 limit회까지 허용한다.
     * 허용되면 true, 한도를 넘어서면 false를 돌려준다(호출자가 429로 막으면 된다).
     *
     * 주의: 이 메서드는 "시도"를 셈에 포함한다 — 넘친 시도도 카운트되지만 어차피 창이
     * 끝날 때까지 계속 거부되므로 결과는 같다(창당 최대 limit회 통과).
     */
    public boolean tryAcquire(String bucket, String key, int limit, Duration window) {
        Instant now = clock.instant();
        String composite = bucket + "|" + key;
        Window updated = windows.compute(composite, (k, prev) -> {
            // 기록이 없거나 창이 이미 끝났으면 새 창을 연다(카운트 1).
            if (prev == null || now.isAfter(prev.expiresAt())) {
                return new Window(now.plus(window), 1);
            }
            // 창이 살아 있으면 카운트만 올린다(창 끝나는 시각은 그대로).
            return new Window(prev.expiresAt(), prev.count() + 1);
        });
        purgeIfLarge(now);
        return updated.count() <= limit;
    }

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