package demo;

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

import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.util.UriComponentsBuilder;

@SpringBootTest
@AutoConfigureMockMvc
class OAuth2LoginFlowTest {

    @Autowired MockMvc mvc;
    @Autowired OAuth2LoginAccountService accounts;

    static final Path OUT = Path.of("cap");

    @BeforeAll
    static void clean() throws IOException {
        Files.createDirectories(OUT);
        try (var s = Files.list(OUT)) { s.forEach(p -> p.toFile().delete()); }
    }

    /** Prints a line AND appends it to cap/NN-name.txt, so every number in the post is an asserted, captured fact. */
    static void say(String file, String line) throws IOException {
        System.out.println(line);
        Files.writeString(OUT.resolve(file), line + System.lineSeparator(), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }

    static Map<String, String> params(String location) {
        Map<String, String> m = new TreeMap<>();
        UriComponentsBuilder.fromUri(URI.create(location)).build().getQueryParams()
                .forEach((k, v) -> m.put(k, v.get(0)));
        return m;
    }

    @Test
    void unauthenticatedBehaviour() throws Exception {
        String f = "01-unauthenticated.txt";
        MockHttpServletResponse api = mvc.perform(get("/api/me")).andReturn().getResponse();
        say(f, "GET /api/me (anonymous)      -> " + api.getStatus());
        MockHttpServletResponse noAccept = mvc.perform(get("/dashboard")).andReturn().getResponse();
        say(f, "GET /dashboard, no Accept    -> " + noAccept.getStatus() + " Location: " + noAccept.getRedirectedUrl());
        MockHttpServletResponse page = mvc.perform(get("/dashboard").accept("text/html")).andReturn().getResponse();
        say(f, "GET /dashboard, Accept html  -> " + page.getStatus() + " Location: " + page.getRedirectedUrl());
        assertThat(noAccept.getStatus()).isEqualTo(401);
        MockHttpServletResponse home = mvc.perform(get("/")).andReturn().getResponse();
        say(f, "GET / (anonymous)            -> " + home.getStatus() + " " + home.getContentAsString());
        assertThat(api.getStatus()).isEqualTo(401);
        assertThat(page.getStatus()).isEqualTo(302);

        String login = mvc.perform(get("/login")).andReturn().getResponse().getContentAsString();
        List<String> hrefs = new ArrayList<>();
        var m = java.util.regex.Pattern.compile("href=\"(/oauth2/authorization/[a-z]+)\"").matcher(login);
        while (m.find()) hrefs.add(m.group(1));
        say(f, "generated /login page links  -> " + hrefs);
        assertThat(hrefs).containsExactlyInAnyOrder("/oauth2/authorization/google", "/oauth2/authorization/github");
    }

    @Test
    void authorizationRedirects() throws Exception {
        String f = "02-redirects.txt";
        for (String id : List.of("google", "github")) {
            MockHttpServletResponse r = mvc.perform(get("/oauth2/authorization/" + id)).andReturn().getResponse();
            String loc = r.getRedirectedUrl();
            say(f, "GET /oauth2/authorization/" + id + " -> " + r.getStatus());
            say(f, "  authorize endpoint : " + loc.substring(0, loc.indexOf('?')));
            Map<String, String> p = params(loc);
            for (String k : List.of("response_type", "client_id", "scope", "redirect_uri")) {
                say(f, "  " + k + " = " + java.net.URLDecoder.decode(p.get(k), java.nio.charset.StandardCharsets.UTF_8));
            }
            say(f, "  has state=" + p.containsKey("state") + "  has nonce=" + p.containsKey("nonce")
                    + "  has code_challenge=" + p.containsKey("code_challenge"));
            assertThat(r.getStatus()).isEqualTo(302);
        }
    }

    @Test
    void sessionLoginAndMeEndpoint() throws Exception {
        String f = "03-me.txt";
        MockHttpServletResponse r = mvc.perform(get("/api/me").with(oidcLogin()
                .idToken(t -> t.subject("g-1001").claim("email", "ankur@example.com"))
                .authorities(new SimpleGrantedAuthority("ROLE_USER"))))
                .andReturn().getResponse();
        say(f, "GET /api/me (oidcLogin)      -> " + r.getStatus() + " " + r.getContentAsString());
        assertThat(r.getStatus()).isEqualTo(200);
    }

    @Test
    void logoutNeedsCsrf() throws Exception {
        String f = "04-logout.txt";
        MockHttpServletResponse noCsrf = mvc.perform(post("/logout").with(oidcLogin())).andReturn().getResponse();
        say(f, "POST /logout without CSRF    -> " + noCsrf.getStatus());
        MockHttpServletResponse withCsrf = mvc.perform(post("/logout").with(oidcLogin()).with(csrf())).andReturn().getResponse();
        say(f, "POST /logout with CSRF       -> " + withCsrf.getStatus() + " Location: " + withCsrf.getRedirectedUrl());
        MockHttpServletResponse get = mvc.perform(get("/logout").with(oidcLogin())).andReturn().getResponse();
        say(f, "GET  /logout                 -> " + get.getStatus());
        assertThat(noCsrf.getStatus()).isEqualTo(403);
        assertThat(withCsrf.getStatus()).isEqualTo(302);
    }

    @Test
    void corsPreflightFromSpa() throws Exception {
        String f = "05-cors.txt";
        MockHttpServletResponse r = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
                .options("/api/me").header("Origin", "http://localhost:5173")
                .header("Access-Control-Request-Method", "GET")).andReturn().getResponse();
        say(f, "OPTIONS /api/me from :5173   -> " + r.getStatus()
                + "  Allow-Origin=" + r.getHeader("Access-Control-Allow-Origin")
                + "  Allow-Credentials=" + r.getHeader("Access-Control-Allow-Credentials"));
        MockHttpServletResponse bad = mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders
                .options("/api/me").header("Origin", "http://evil.example")
                .header("Access-Control-Request-Method", "GET")).andReturn().getResponse();
        say(f, "OPTIONS /api/me from evil    -> " + bad.getStatus());
        assertThat(r.getHeader("Access-Control-Allow-Origin")).isEqualTo("http://localhost:5173");
    }

    @Test
    void accountMappingKeysOnStableId() throws Exception {
        String f = "06-mapping.txt";
        OidcIdToken t1 = new OidcIdToken("t", Instant.now(), Instant.now().plusSeconds(60),
                Map.of("sub", "g-777", "email", "old@example.com", "name", "Ankur"));
        OidcIdToken t2 = new OidcIdToken("t", Instant.now(), Instant.now().plusSeconds(60),
                Map.of("sub", "g-777", "email", "new@example.com", "name", "Ankur M"));
        accounts.mapOidc("google", new DefaultOidcUser(List.of(), t1));
        accounts.mapOidc("google", new DefaultOidcUser(List.of(), t2)); // same person, email changed
        say(f, "google sub=g-777, email changed  -> local accounts: " + accounts.count());
        assertThat(accounts.count()).isEqualTo(1);

        var gh1 = new DefaultOAuth2User(List.of(), Map.of("id", 4242, "login", "ankur-old"), "id");
        var gh2 = new DefaultOAuth2User(List.of(), Map.of("id", 4242, "login", "ankur-renamed"), "id");
        accounts.mapGithub(gh1);
        var mapped = accounts.mapGithub(gh2); // same numeric id, login renamed, no public email
        say(f, "github id=4242, login renamed    -> local accounts: " + accounts.count());
        say(f, "github email attribute           -> " + mapped.getAttribute("email"));
        say(f, "authorities after mapping        -> "
                + mapped.getAuthorities().stream().map(Object::toString).sorted().toList());
        assertThat(accounts.count()).isEqualTo(2);

        accounts.findOrCreate("github", "ankur@example.com", null, "email-keyed (wrong)");
        say(f, "if keyed by email instead        -> a third, duplicate-prone row: " + accounts.count());
    }
}
