package dev.awesomedev.mirim.web;

import dev.awesomedev.mirim.domain.User;
import dev.awesomedev.mirim.service.AuthService;
import dev.awesomedev.mirim.service.LoginAttemptService;
import dev.awesomedev.mirim.web.dto.FindIdRequest;
import dev.awesomedev.mirim.web.dto.FindIdResponse;
import dev.awesomedev.mirim.web.dto.LoginRequest;
import dev.awesomedev.mirim.web.dto.ChangePasswordRequest;
import dev.awesomedev.mirim.web.dto.SignupRequest;
import dev.awesomedev.mirim.web.dto.SignupResponse;
import dev.awesomedev.mirim.web.dto.UserResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * 이 파일이 하는 일:
 * 로그인 / 로그아웃 / 내 정보 조회 / 회원가입 / 아이디 찾기 / 비밀번호 재설정
 * API를 제공하는 컨트롤러다.
 *
 * 학습 포인트: 보통 Spring Security 예제는 AuthenticationManager와 formLogin을 쓰지만,
 * 여기서는 "무슨 일이 일어나는지" 눈에 다 보이도록 직접 구현한다:
 *   1) UserRepository로 사용자를 찾고 PasswordEncoder로 비밀번호를 검증한다 (AuthService).
 *   2) 검증에 성공하면 Authentication 객체를 직접 만든다.
 *   3) SecurityContextRepository로 그 인증 정보를 세션에 저장한다.
 * 이후 요청부터는 Spring Security가 세션에서 인증 정보를 꺼내 "로그인된 상태"로 처리한다.
 */
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;
    private final LoginAttemptService loginAttemptService;
    private final SecurityContextRepository securityContextRepository;

    public AuthController(AuthService authService,
                          LoginAttemptService loginAttemptService,
                          SecurityContextRepository securityContextRepository) {
        this.authService = authService;
        this.loginAttemptService = loginAttemptService;
        this.securityContextRepository = securityContextRepository;
    }

    /** POST /api/auth/login — 로그인. 성공 시 세션이 만들어지고 사용자 정보를 돌려준다. */
    @PostMapping("/login")
    public UserResponse login(@Valid @RequestBody LoginRequest loginRequest,
                              HttpServletRequest request,
                              HttpServletResponse response) {
        // 0) 무차별 대입 방어 — 비밀번호를 검증하기 "전에" 잠금부터 확인한다.
        //    잠긴 상태에서는 맞는 비밀번호를 넣어도 검증 자체를 하지 않는다.
        //    (안 그러면 공격자가 잠금 중에도 계속 정답 후보를 확인해 볼 수 있다.)
        String clientIp = clientIp(request);
        if (loginAttemptService.isLocked(loginRequest.username(), clientIp)) {
            throw new org.springframework.web.server.ResponseStatusException(
                    HttpStatus.TOO_MANY_REQUESTS, "로그인 시도가 너무 많습니다. 5분 뒤에 다시 시도해 주세요.");
        }

        // 1) 아이디·비밀번호 검증 (틀리면 AuthService가 401을 던진다)
        User user;
        try {
            user = authService.authenticate(loginRequest.username(), loginRequest.password());
        } catch (org.springframework.web.server.ResponseStatusException e) {
            // 실패를 기록하고 원래 예외(401)를 그대로 다시 던진다.
            loginAttemptService.recordFailure(loginRequest.username(), clientIp);
            throw e;
        }
        // 성공하면 실패 기록을 지운다 — 실수 몇 번 했다고 다음 실수까지 이어서 세지 않는다.
        loginAttemptService.reset(loginRequest.username(), clientIp);

        // 1-1) 세션 ID 재발급 — 세션 고정(session fixation) 공격 방어.
        // 학습 포인트: 공격자가 자기 세션 ID를 피해자 브라우저에 미리 심어 둔 뒤,
        // 피해자가 그 세션으로 로그인하면 "공격자가 아는 ID"가 그대로 인증된 세션이 된다.
        // 로그인 순간 ID를 새로 발급하면 심어 둔 ID는 무용지물이 된다.
        // (formLogin을 쓰면 스프링이 자동으로 해주지만, 우리는 직접 구현했으므로 직접 챙긴다 —
        //  프레임워크를 걷어낼 땐 그것이 대신 해주던 일까지 함께 가져와야 한다.)
        //
        // 학습 포인트 ②: getSession(false)로 먼저 확인하는 이유 —
        // changeSessionId()는 "바꿀 세션이 없으면" IllegalStateException을 던진다(500 에러!).
        // 앱/curl처럼 쿠키 없이 바로 로그인하면 세션이 없는 게 정상이므로 그때는 건너뛴다.
        // 어차피 아래 saveContext()가 새 세션을 만들고, 새 세션의 ID는 아무도 모르니 안전하다.
        // → "메서드가 어떤 조건에서 예외를 던지는지"를 문서에서 확인하는 습관이 이래서 중요하다.
        if (request.getSession(false) != null) {
            request.changeSessionId();
        }

        // 2) 인증 토큰 생성. principal은 username, 권한은 "ROLE_" + 역할.
        //    학습 포인트: Spring Security의 hasRole("MENTOR")는 내부적으로 "ROLE_MENTOR" 권한을 찾는다.
        //    그래서 권한 이름 앞에 반드시 "ROLE_" 접두어를 붙여야 한다.
        Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(
                user.getUsername(),
                null, // 비밀번호는 인증이 끝났으니 보관하지 않는다
                List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole())));

        // 3) SecurityContext를 만들어 세션에 저장 — 이 한 줄이 "로그인 상태 유지"의 핵심이다.
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        SecurityContextHolder.setContext(context);
        securityContextRepository.saveContext(context, request, response);

        return UserResponse.from(user);
    }

    /** POST /api/auth/logout — 로그아웃. 세션을 통째로 무효화하고 204를 돌려준다. */
    @PostMapping("/logout")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false); // false: 세션이 없으면 새로 만들지 않는다
        if (session != null) {
            session.invalidate();
        }
        SecurityContextHolder.clearContext();
    }

    /** GET /api/auth/me — 지금 로그인한 사용자 정보. 로그인 안 했으면 401(SecurityConfig가 처리). */
    @GetMapping("/me")
    public UserResponse me(Authentication authentication) {
        User user = authService.requireUser(authentication);
        return UserResponse.from(user);
    }

    /**
     * POST /api/auth/signup — 회원가입. 성공하면 201 Created와 함께 만들어진 계정 정보를 준다.
     *
     * 학습 포인트: @Valid가 붙어 있으면 SignupRequest의 검증 애노테이션
     * (@NotBlank, @Size, @Pattern)이 이 메서드가 실행되기 전에 먼저 검사된다.
     * 검사에 실패하면 스프링이 알아서 400 Bad Request를 돌려주므로,
     * 컨트롤러 안에는 "규칙을 통과한 값"만 들어온다고 믿고 코드를 쓸 수 있다.
     * 가입 후 자동 로그인은 하지 않는다 — 로그인 화면에서 직접 로그인해 보는 것도 학습의 일부다.
     */
    @PostMapping("/signup")
    @ResponseStatus(HttpStatus.CREATED) // 자원(계정)이 새로 생겼으므로 REST 관례대로 201
    public SignupResponse signup(@Valid @RequestBody SignupRequest signupRequest) {
        User user = authService.signup(signupRequest);
        return SignupResponse.from(user);
    }

    /**
     * POST /api/auth/find-id — 이름으로 아이디 찾기. 아이디는 마스킹해서 준다(me****1).
     * 이름이 일치하는 계정이 없으면 빈 배열을 돌려준다(에러가 아니다).
     * 왜 마스킹하는지는 AuthService.findUsernamesByName 주석 참고 — 보안 검토의 실제 교훈이 담겨 있다.
     */
    @PostMapping("/find-id")
    public FindIdResponse findId(@Valid @RequestBody FindIdRequest findIdRequest) {
        return new FindIdResponse(authService.findUsernamesByName(findIdRequest.name()));
    }

    /**
     * POST /api/auth/change-password — 로그인한 본인의 비밀번호 변경.
     * 현재 비밀번호를 함께 보내야 한다(재인증). 틀리면 401.
     *
     * 학습 포인트: 이 API는 SecurityConfig의 "/api/** → authenticated()" 규칙에 걸려
     * 로그인한 사람만 호출할 수 있다. permitAll 목록에 없다는 것 자체가 보안 설계다.
     */
    @PostMapping("/change-password")
    public void changePassword(@Valid @RequestBody ChangePasswordRequest changePasswordRequest,
                               Authentication authentication) {
        User user = authService.requireUser(authentication);
        authService.changePassword(user,
                changePasswordRequest.currentPassword(),
                changePasswordRequest.newPassword());
        // 반환할 내용이 없으므로 본문 없이 200 OK만 내려간다.
    }

    /**
     * 요청을 보낸 클라이언트의 IP를 알아낸다 (로그인 잠금의 키로 쓴다).
     *
     * 학습 포인트: 우리 백엔드 앞에는 Caddy(리버스 프록시)가 있다. 그래서
     * request.getRemoteAddr()는 항상 "Caddy의 IP"다 — 이걸 키로 쓰면 전 세계 사용자가
     * 한 명으로 묶여 버린다. 진짜 클라이언트 IP는 Caddy가 X-Forwarded-For 헤더에 담아 준다.
     *
     * ⚠️ 이 헤더를 믿어도 되는 조건: 백엔드가 오직 Caddy를 통해서만 접근 가능할 때다
     * (우리 구성이 그렇다 — backend 컨테이너는 외부 포트를 열지 않는다).
     * 백엔드가 인터넷에 직접 노출돼 있다면 누구나 이 헤더를 위조할 수 있으므로 믿으면 안 된다.
     */
    private String clientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            // 프록시를 여러 번 거치면 "원래IP, 프록시1, 프록시2"처럼 쌓인다 — 맨 앞이 클라이언트.
            return forwarded.split(",")[0].trim();
        }
        return request.getRemoteAddr();
    }
}
