package kr.itn.itnhub.config;

import jakarta.servlet.http.Cookie;
import kr.itn.itnhub.AbstractDbTest;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

/**
 * 실행 순서를 명시적으로 고정한다({@code @TestMethodOrder}). 이유: Spring Security
 * Test의 {@code with(csrf())}는 최초 호출 시 리플렉션으로 캐시된 애플리케이션 컨텍스트의
 * 실제 {@code CsrfFilter} 빈의 {@code tokenRepository} 필드를 세션 기반 테스트 저장소로
 * "영구히" 바꿔치기한다({@code WebTestUtils.setCsrfTokenRepository} 참고). 그 뒤로는 같은
 * 컨텍스트를 공유하는 이 클래스의 모든 테스트에서 실제 {@code CookieCsrfTokenRepository}가
 * 더 이상 동작하지 않는다. 그래서 실제 쿠키 왕복을 검증하는 테스트는 {@code with(csrf())}를
 * 쓰는 테스트보다 반드시 먼저 실행돼야 한다.
 */
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class SecurityConfigTest extends AbstractDbTest {

    @Autowired
    MockMvc mvc;

    /**
     * Finding 1 회귀 테스트. {@code .with(csrf())}를 전혀 쓰지 않는다.
     * 브라우저가 실제로 겪는 흐름 그대로: 먼저 permitAll 경로에 평범한 GET을 보내
     * 응답의 Set-Cookie로 XSRF-TOKEN을 받고, 그 쿠키 값을 X-XSRF-TOKEN 헤더로
     * 되돌려 보내 로그인한다. CookieCsrfTokenRepository가 실제로 쿠키를 쓰지 않으면
     * (지연 토큰이 resolve되지 않으면) xsrfCookie가 null이 되어 이 테스트는 실패한다.
     */
    @Test
    @Order(1)
    void CSRF_쿠키를_받아서_로그인에_사용할_수_있다() throws Exception {
        MvcResult csrfResult = mvc.perform(get("/")).andReturn();
        Cookie xsrfCookie = csrfResult.getResponse().getCookie("XSRF-TOKEN");
        assertThat(xsrfCookie).as("XSRF-TOKEN 쿠키가 Set-Cookie로 내려와야 한다").isNotNull();

        mvc.perform(post("/api/auth/login")
                        .param("username", "admin")
                        .param("password", "test-password")
                        .cookie(xsrfCookie)
                        .header("X-XSRF-TOKEN", xsrfCookie.getValue()))
                .andExpect(status().isOk());
    }

    /**
     * CSRF 토큰을 전혀 보내지 않으면 CsrfFilter 자신의 AccessDeniedHandler가
     * 처리한다(이 프로젝트가 exceptionHandling에 설정한 401 엔트리포인트를 타지 않는다).
     * 실측 결과 403 Forbidden이었다.
     */
    @Test
    @Order(2)
    void CSRF_토큰_없이_로그인하면_거부된다() throws Exception {
        mvc.perform(post("/api/auth/login")
                        .param("username", "admin")
                        .param("password", "test-password"))
                .andExpect(status().isForbidden());
    }

    /**
     * Finding 4. formLogin()에 커스텀 loginPage를 지정하면
     * DefaultLoginPageGeneratingFilter가 등록되지 않아야 한다.
     */
    @Test
    @Order(3)
    void 로그인_페이지는_스프링_기본_로그인폼을_반환하지_않는다() throws Exception {
        MvcResult result = mvc.perform(get("/login")).andReturn();
        String body = result.getResponse().getContentAsString();
        assertThat(body).doesNotContain("Please sign in");
    }

    @Test
    @Order(4)
    void 인증없이_api를_부르면_401이다() throws Exception {
        mvc.perform(get("/api/orgs"))
                .andExpect(status().isUnauthorized());
    }

    /**
     * Finding 2. 예전 테스트는 틀린 자격증명으로 로그인해 401을 확인하는 것만으로
     * "인증 없이 접근 가능"을 증명하려 했지만, permitAll()이 삭제돼 /api/** →
     * authenticated()로 떨어져도 같은 401이 나와 아무것도 구분하지 못했다.
     * 실제로는 두 401이 서로 다른 필터에서 나온다:
     *  - 로그인 실패: UsernamePasswordAuthenticationFilter가 바로 failureHandler를
     *    호출하고 끝난다. ExceptionTranslationFilter/AuthorizationFilter를 거치지
     *    않으므로 세션이 생기지 않는다.
     *  - 인가 차단: ExceptionTranslationFilter.sendStartAuthentication()이 엔트리
     *    포인트를 부르기 전에 기본 RequestCache(HttpSessionRequestCache)로 원 요청을
     *    세션에 저장한다. 즉 세션이 생긴다.
     * 이 세션 생성 여부 차이로 두 경로가 실제로 다르다는 것을, 즉 permitAll()이
     * 로그인 엔드포인트에서 실제로 작동하고 있다는 것을 증명한다.
     */
    @Test
    @Order(5)
    void 로그인_엔드포인트는_인증없이_접근할_수_있다() throws Exception {
        MvcResult loginResult = mvc.perform(post("/api/auth/login")
                        .param("username", "nobody")
                        .param("password", "nothing")
                        .with(csrf()))
                .andExpect(status().isUnauthorized()) // 403이 아니라 401이어야 한다
                .andReturn();
        assertThat(loginResult.getRequest().getSession(false))
                .as("인증 실패는 AuthorizationFilter를 거치지 않으므로 세션이 생기면 안 된다")
                .isNull();

        MvcResult blockedResult = mvc.perform(get("/api/orgs"))
                .andExpect(status().isUnauthorized())
                .andReturn();
        assertThat(blockedResult.getRequest().getSession(false))
                .as("인가 차단은 ExceptionTranslationFilter가 원 요청을 세션에 저장한다")
                .isNotNull();
    }

    @Test
    @Order(6)
    void 올바른_비밀번호로_로그인하면_200이다() throws Exception {
        mvc.perform(post("/api/auth/login")
                        .param("username", "admin")
                        .param("password", "test-password")
                        .with(csrf()))
                .andExpect(status().isOk());
    }

    @Test
    @Order(7)
    void 틀린_비밀번호로_로그인하면_401이다() throws Exception {
        mvc.perform(post("/api/auth/login")
                        .param("username", "admin")
                        .param("password", "wrong")
                        .with(csrf()))
                .andExpect(status().isUnauthorized());
    }
}
