package com.pandoli365.bibimbap.security;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import com.pandoli365.bibimbap.security.SsrfSafeFetcher.FetchResult;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

class SsrfSafeFetcherTest {

    private static final Set<String> HTML = Set.of("text/html");
    private static final long MAX = 64 * 1024;

    private HttpServer server;

    @AfterEach
    void stopServer() {
        if (server != null) {
            server.stop(0);
            server = null;
        }
    }

    /**
     * resolve() 를 stub 으로 주입하고, 로컬 HttpServer 실측을 위해 loopback 차단을
     * 선택적으로 무력화할 수 있는 테스트 전용 서브클래스.
     */
    static class TestableFetcher extends SsrfSafeFetcher {
        final Map<String, InetAddress[]> stub = new HashMap<>();
        private boolean allowAnyPort = false;

        TestableFetcher allowLoopback() {
            this.allowLoopbackForTest = true;
            // 로컬 HttpServer 는 ephemeral 포트를 쓰므로 포트 allowlist 를 완화한다(테스트 전용).
            this.allowAnyPort = true;
            return this;
        }

        @Override
        protected boolean isAllowedPort(int port) {
            return allowAnyPort || super.isAllowedPort(port);
        }

        @Override
        protected InetAddress[] resolve(String host) throws UnknownHostException {
            InetAddress[] stubbed = stub.get(host);
            if (stubbed != null) {
                return stubbed;
            }
            return super.resolve(host);
        }
    }

    private static InetAddress addr(String literal) throws UnknownHostException {
        return InetAddress.getByName(literal);
    }

    // ── #1 scheme allowlist ──────────────────────────────────────────────

    @Test
    void rejectsNonHttpSchemes() {
        SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
        assertTrue(fetcher.fetch(URI.create("file:///etc/passwd"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("gopher://example.com/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("ftp://example.com/"), MAX, HTML).isEmpty());
        // 유효한 data URI (불법 <> 문자 없이) — scheme 거부 의도만 입증.
        assertTrue(fetcher.fetch(URI.create("data:text/plain;base64,aGk="), MAX, HTML).isEmpty());
    }

    // ── #2 resolve → IP 공인 (사설 IP 직접) ───────────────────────────────

    @Test
    void rejectsPrivateIpLiteral() {
        SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
        assertTrue(fetcher.fetch(URI.create("http://10.0.0.1/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("http://192.168.1.1/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("http://172.16.0.1/"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://10.0.0.1/")));
    }

    // ── #3 loopback ──────────────────────────────────────────────────────

    @Test
    void rejectsLoopback() {
        SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
        assertTrue(fetcher.fetch(URI.create("http://127.0.0.1/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("http://[::1]/"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://127.0.0.1/")));
    }

    // ── #4 (3항목 메타데이터) 메타데이터 엔드포인트 ──────────────────────

    @Test
    void rejectsMetadataEndpoint() {
        SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
        assertTrue(fetcher.fetch(
                URI.create("http://169.254.169.254/latest/meta-data"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.169.254/")));
    }

    // ── #5 (3항목 link-local) 링크로컬 ───────────────────────────────────

    @Test
    void rejectsLinkLocal() {
        SsrfSafeFetcher fetcher = new SsrfSafeFetcher();
        assertTrue(fetcher.fetch(URI.create("http://169.254.1.1/"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://169.254.1.1/")));
    }

    // ── #6 (3항목 IPv4-mapped IPv6) ──────────────────────────────────────

    @Test
    void rejectsIpv4MappedIpv6() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        // host 가 ::ffff:127.0.0.1 (loopback) / ::ffff:10.0.0.1 (사설) 로 해석되는 시나리오.
        fetcher.stub.put("mapped-loopback.test", new InetAddress[] { addr("::ffff:7f00:1") });
        fetcher.stub.put("mapped-private.test", new InetAddress[] { addr("::ffff:0a00:1") });

        assertTrue(fetcher.fetch(URI.create("http://mapped-loopback.test/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("http://mapped-private.test/"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://mapped-private.test/")));
    }

    // ── #7 DNS rebinding mock (resolve→사설) ─────────────────────────────

    @Test
    void rejectsRebindingToPrivateIp() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        // 정상 외부처럼 보이는 host 가 실제로는 사설 IP 로 resolve 되는 rebinding 시나리오.
        // IP 핀닝 구현은 resolve 결과 IP 자체를 검증하므로 사설 반환 시 차단된다.
        fetcher.stub.put("evil-rebind.test", new InetAddress[] { addr("10.0.0.5") });
        fetcher.stub.put("evil-loopback.test", new InetAddress[] { addr("127.0.0.1") });

        assertTrue(fetcher.fetch(URI.create("http://evil-rebind.test/"), MAX, HTML).isEmpty());
        assertTrue(fetcher.fetch(URI.create("http://evil-loopback.test/"), MAX, HTML).isEmpty());
        assertFalse(fetcher.isFetchableUrl(URI.create("http://evil-rebind.test/")));
    }

    @Test
    void rejectsMixedPublicAndPrivateIps() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        // 하나라도 사설이면 거부 (전부 공인이어야 통과).
        fetcher.stub.put("mixed.test",
                new InetAddress[] { addr("93.184.216.34"), addr("10.0.0.9") });
        assertFalse(fetcher.isFetchableUrl(URI.create("http://mixed.test/")));
        assertTrue(fetcher.fetch(URI.create("http://mixed.test/"), MAX, HTML).isEmpty());
    }

    // ── fix1 결함2: 확장 예약대역 차단 (CGNAT 등) ─────────────────────────

    @Test
    void rejectsCgnatAddress() throws Exception {
        // CGNAT 100.64.0.0/10 — isSiteLocalAddress=false 라 기존 갭. 클라우드 내부 라우팅 차단.
        TestableFetcher fetcher = new TestableFetcher();
        fetcher.stub.put("cgnat.test", new InetAddress[] { addr("100.64.1.1") });
        fetcher.stub.put("cgnat-edge.test", new InetAddress[] { addr("100.127.255.254") });

        assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat.test/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://cgnat-edge.test/")));
        assertTrue(fetcher.fetch(URI.create("http://cgnat.test/"), MAX, HTML).isEmpty());
        // 인접 공인 대역(100.63.x.x, 100.128.x.x)은 CGNAT 가 아니므로 차단 대상 아님 (경계 입증).
        fetcher.stub.put("below-cgnat.test", new InetAddress[] { addr("100.63.255.255") });
        assertTrue(fetcher.isFetchableUrl(URI.create("http://below-cgnat.test/")),
                "100.63.x 는 CGNAT 미만이므로 차단되지 않아야 한다");
    }

    @Test
    void rejectsOtherReservedV4Ranges() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        fetcher.stub.put("class-e.test", new InetAddress[] { addr("240.0.0.1") });
        fetcher.stub.put("bench.test", new InetAddress[] { addr("198.18.0.1") });
        fetcher.stub.put("ietf.test", new InetAddress[] { addr("192.0.0.1") });
        fetcher.stub.put("bcast.test", new InetAddress[] { addr("255.255.255.255") });

        assertFalse(fetcher.isFetchableUrl(URI.create("http://class-e.test/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://bench.test/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://ietf.test/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://bcast.test/")));
    }

    @Test
    void rejectsNat64AndSixToFourEmbeddingPrivateV4() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        // NAT64 64:ff9b::/96 임베드 v4 = 10.0.0.1 (사설).
        fetcher.stub.put("nat64.test", new InetAddress[] { addr("64:ff9b::a00:1") });
        // 6to4 2002::/16 임베드 v4 = 192.168.0.1 (사설) → 2002:c0a8:1::.
        fetcher.stub.put("sixtofour.test", new InetAddress[] { addr("2002:c0a8:1::1") });

        assertFalse(fetcher.isFetchableUrl(URI.create("http://nat64.test/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://sixtofour.test/")));
    }

    // ── fix1 결함3: 포트 allowlist ─────────────────────────────────────────

    @Test
    void rejectsNonAllowedPort() throws Exception {
        TestableFetcher fetcher = new TestableFetcher();
        // 공인 IP 라도 비-웹 포트(22/6379/5432)는 거부.
        fetcher.stub.put("pub.test", new InetAddress[] { addr("93.184.216.34") });
        assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:22/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("http://pub.test:6379/")));
        assertFalse(fetcher.isFetchableUrl(URI.create("https://pub.test:5432/")));
        assertTrue(fetcher.fetch(URI.create("http://pub.test:22/"), MAX, HTML).isEmpty());
        // 허용 포트 + scheme 기본포트(미명시)는 통과.
        assertTrue(fetcher.isFetchableUrl(URI.create("http://pub.test:8080/")));
        assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test:8443/")));
        assertTrue(fetcher.isFetchableUrl(URI.create("https://pub.test/")));
    }

    // ── #8 (항목5) redirect → 사설 재검증 차단 ───────────────────────────

    @Test
    void rejectsRedirectTargetToPrivateHost() throws Exception {
        // redirect 대상 host 가 사설 IP 로 resolve 되면 재검증에서 차단됨을 정적 입증.
        TestableFetcher fetcher = new TestableFetcher();
        fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });
        assertFalse(fetcher.isFetchableUrl(URI.create("http://internal-redirect.test/admin")));
    }

    @Test
    void redirectToPrivateHostIsBlockedEndToEnd() throws Exception {
        // 로컬 서버(공인처럼 통과)가 사설 host 로 302 redirect → 다음 홉 재검증 차단.
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        fetcher.stub.put("internal-redirect.test", new InetAddress[] { addr("10.1.2.3") });

        startServer(exchange -> {
            exchange.getResponseHeaders().add("Location", "http://internal-redirect.test/secret");
            exchange.sendResponseHeaders(302, -1);
            exchange.close();
        });

        Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
        assertTrue(result.isEmpty(), "redirect 대상이 사설이면 empty 여야 한다");
    }

    @Test
    void exceedingMaxRedirectsIsBlocked() throws Exception {
        // 무한/과다 redirect → MAX_REDIRECTS 초과 시 abort.
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        startServer(exchange -> {
            exchange.getResponseHeaders().add("Location", "/loop");
            exchange.sendResponseHeaders(302, -1);
            exchange.close();
        });
        Optional<FetchResult> result = fetcher.fetch(localUri("/loop"), MAX, HTML);
        assertTrue(result.isEmpty(), "redirect 홉 초과 시 empty 여야 한다");
    }

    // ── #9 size / timeout / Content-Type + 정상 (로컬 서버 실측, graceful) ──

    @Test
    void rejectsOversizedBody() throws Exception {
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        byte[] big = new byte[(int) MAX + 1024];
        startServer(exchange -> {
            exchange.getResponseHeaders().add("Content-Type", "text/html");
            exchange.sendResponseHeaders(200, big.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(big);
            }
        });
        Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
        assertTrue(result.isEmpty(), "maxBytes 초과 본문은 empty 여야 한다");
    }

    @Test
    void rejectsOnTimeout() throws Exception {
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        startServer(exchange -> {
            try {
                // REQUEST_TIMEOUT(5s) 보다 길게 지연 → request timeout 발동.
                Thread.sleep(7000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            try {
                exchange.getResponseHeaders().add("Content-Type", "text/html");
                exchange.sendResponseHeaders(200, -1);
                exchange.close();
            } catch (IOException ignored) {
                // 클라이언트 timeout 후 응답 시도는 무시.
            }
        });
        Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
        assertTrue(result.isEmpty(), "응답 지연 시 timeout 으로 empty 여야 한다");
    }

    @Test
    void rejectsDisallowedContentType() throws Exception {
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        startServer(exchange -> writeBody(exchange, "text/plain", "hello"));
        Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
        assertTrue(result.isEmpty(), "허용되지 않은 Content-Type 은 empty 여야 한다");
    }

    @Test
    void fetchesAllowedHtmlSuccessfully() throws Exception {
        TestableFetcher fetcher = new TestableFetcher().allowLoopback();
        String html = "<html><head><title>ok</title></head><body>hi</body></html>";
        startServer(exchange -> writeBody(exchange, "text/html; charset=utf-8", html));

        Optional<FetchResult> result = fetcher.fetch(localUri("/"), MAX, HTML);
        assertTrue(result.isPresent(), "정상 text/html 은 present 여야 한다");
        assertEquals(200, result.get().status());
        assertEquals("text/html", result.get().contentType());
        assertEquals(html, new String(result.get().body(), StandardCharsets.UTF_8));
    }

    // ── helpers ──────────────────────────────────────────────────────────

    private void startServer(HttpHandler handler) throws IOException {
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.createContext("/", handler);
        server.start();
    }

    private URI localUri(String path) {
        return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + path);
    }

    private static void writeBody(HttpExchange exchange, String contentType, String body) {
        try {
            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
            exchange.getResponseHeaders().add("Content-Type", contentType);
            exchange.sendResponseHeaders(200, bytes.length);
            try (OutputStream os = exchange.getResponseBody()) {
                os.write(bytes);
            }
        } catch (IOException ignored) {
            // 테스트 핸들러 best-effort.
        }
    }
}
