package com.pandoli365.bibimbap.security;

import java.io.IOException;
import java.io.InputStream;
import java.net.Inet4Address;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;

import org.springframework.stereotype.Component;

/**
 * SSRF 방어를 적용한 외부 URL fetcher.
 *
 * <p>외부에서 입력된 URL(피드 링크, OG 프리뷰 대상 등)을 가져올 때 내부망/메타데이터
 * 엔드포인트로의 요청을 차단한다. JDK {@link HttpClient}를 followRedirects=NEVER 로 두고
 * 매 홉마다 host 를 직접 resolve → 전 IP 검증 후 연결하는 방식으로 DNS rebinding 을 방어한다.
 *
 * <h2>연결 메커니즘 (W3-3 fix1, 결함1)</h2>
 * <p>이전 구현은 검증된 IP literal 을 authority 로 핀닝하고 {@code Host} 헤더를 원본 host 로
 * 덮어썼다. 그러나 (1) JDK {@link HttpClient} 는 {@code Host} 를 restricted header 로 취급해
 * {@code HttpRequest.build()} 에서 {@code IllegalArgumentException} 을 던지므로 모든 fetch 가
 * 실패했고, (2) IP literal 로 connect 하면 HTTPS 의 SNI/인증서 hostname 검증이 IP 기준이 되어
 * 실제 HTTPS 대상(유니티 피드/OG 다수)의 TLS 검증이 깨진다.
 *
 * <p>이를 해소하기 위해 <b>option (b): 원본 hostname 으로 connect</b> 하되, connect 전에
 * resolve 한 <b>모든 IP 를 검증</b>(전부 공인이어야 통과)하고, 비-loopback host 에 대해서는
 * connect 직전에 <b>실제 사용될 peer IP 를 한 번 더 재검증</b>(rebinding 방어 유지)한다.
 * hostname 으로 connect 하므로 HTTPS SNI/인증서 검증이 정상 동작하고({@code Host} 헤더 override
 * 불필요 → restricted header 예외 제거), 사설/예약 IP 로 rebinding 되는 입력은 pre-connect 및
 * re-validate 양 단계에서 차단된다.
 *
 * <p>잔여 TOCTOU(검증 시점 IP 와 HttpClient 가 실제 connect 하는 IP 의 불일치 가능성)는 JVM
 * positive DNS 캐시({@code networkaddress.cache.ttl})로 동일 lookup 이 재사용되도록 두어 창을
 * 최소화한다. 추가로 매 홉 connect 직전 재검증을 수행한다.
 */
@Component
public class SsrfSafeFetcher {

    static final int MAX_REDIRECTS = 3;

    private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
    private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5);

    private static final String METADATA_IPV4 = "169.254.169.254";

    /**
     * 결함3: 포트 allowlist. OG/피드 대상은 웹서버이므로 표준 웹 포트만 허용한다.
     * 비명시 포트(scheme 기본포트)는 scheme 에 따라 80/443 으로 간주해 통과시킨다.
     */
    private static final Set<Integer> ALLOWED_PORTS = Set.of(80, 443, 8080, 8443);

    /**
     * 검증·핀닝을 거친 단일 홉 요청 결과. 응답 본문은 maxBytes 로 제한된 byte[] 이다.
     */
    public record FetchResult(int status, String contentType, byte[] body, URI finalUrl) {
    }

    private final HttpClient httpClient = HttpClient.newBuilder()
            .followRedirects(Redirect.NEVER)
            .connectTimeout(CONNECT_TIMEOUT)
            .build();

    /**
     * 외부 URL 을 SSRF 방어 하에 가져온다.
     *
     * <p>scheme/port/IP/redirect/size/timeout/Content-Type 검증 중 하나라도 실패하거나 예외가
     * 발생하면 {@link Optional#empty()} 를 반환한다(예외를 호출자에 전파하지 않는다).
     *
     * @param url                 가져올 URL
     * @param maxBytes            응답 본문 누적 허용 바이트 (초과 시 거부)
     * @param allowedContentTypes 허용할 media type 집합 (파라미터 제외, 소문자 비교)
     */
    public Optional<FetchResult> fetch(URI url, long maxBytes, Set<String> allowedContentTypes) {
        try {
            URI current = url;
            for (int hop = 0; hop <= MAX_REDIRECTS; hop++) {
                // #1 scheme, #1b port allowlist, #2 resolve, #3 전 IP 검증.
                ValidatedTarget target = validate(current);
                if (target == null) {
                    return Optional.empty();
                }

                // #4 connect 직전 peer IP 재검증 (rebinding 방어 유지). loopback 테스트 host 는
                //    allowLoopbackForTest 훅 경로에서 이미 통과 판정됨.
                if (!revalidatePeer(target.host())) {
                    return Optional.empty();
                }

                // 원본 hostname 으로 connect → HTTPS SNI/인증서 검증 정상. Host 헤더 override 없음.
                HttpRequest request = HttpRequest.newBuilder()
                        .uri(target.requestUri())
                        .timeout(REQUEST_TIMEOUT)
                        .GET()
                        .build();

                HttpResponse<InputStream> response = httpClient.send(request, BodyHandlers.ofInputStream());
                int status = response.statusCode();

                // #5 redirect 매 홉 재검증: Location 을 새 URI 로 만들고 루프 상단에서 재검증.
                if (isRedirect(status)) {
                    Optional<String> location = response.headers().firstValue("Location");
                    drain(response.body());
                    if (location.isEmpty()) {
                        return Optional.empty();
                    }
                    current = current.resolve(location.get());
                    continue;
                }

                // #8 Content-Type 검증 (media type 만, 소문자).
                String contentType = response.headers().firstValue("Content-Type").orElse("");
                String mediaType = mediaType(contentType);
                if (!allowedContentTypes.contains(mediaType)) {
                    drain(response.body());
                    return Optional.empty();
                }

                // #6 size cap: Content-Length 신뢰 금지, 스트림에서 누적 카운트.
                byte[] body = readCapped(response.body(), maxBytes);
                if (body == null) {
                    return Optional.empty();
                }

                return Optional.of(new FetchResult(status, mediaType, body, current));
            }
            // redirect 홉 초과.
            return Optional.empty();
        } catch (Exception e) {
            // #9 graceful: timeout/IOException/InterruptedException 등 전 경로 차단.
            if (e instanceof InterruptedException) {
                Thread.currentThread().interrupt();
            }
            return Optional.empty();
        }
    }

    /**
     * fetch 없이 정적으로 #1(scheme) + #1b(port) + #2(resolve) + #3(전 IP 공인) 만 검사한다.
     * 피드 등록 등 빠른 거부 판정에 사용한다. 예외는 false.
     */
    public boolean isFetchableUrl(URI url) {
        try {
            return validate(url) != null;
        } catch (Exception e) {
            return false;
        }
    }

    /**
     * #1~#3 검증 후 connect 대상(원본 hostname authority + 검증 통과한 host)을 만든다.
     * 검증 실패 시 null 을 반환한다.
     */
    private ValidatedTarget validate(URI url) {
        if (url == null) {
            return null;
        }
        // #1 scheme allowlist.
        String scheme = url.getScheme();
        if (scheme == null) {
            return null;
        }
        scheme = scheme.toLowerCase(Locale.ROOT);
        if (!scheme.equals("http") && !scheme.equals("https")) {
            return null;
        }

        String host = url.getHost();
        if (host == null || host.isBlank()) {
            return null;
        }

        // #1b 포트 allowlist (결함3). 명시 안 된 경우 scheme 기본포트(http=80/https=443)는 허용.
        int port = url.getPort();
        if (port >= 0 && !isAllowedPort(port)) {
            return null;
        }

        InetAddress[] addrs;
        try {
            // #2 host resolve → 전 IP 해석.
            addrs = resolve(host);
        } catch (UnknownHostException e) {
            return null;
        }
        if (addrs.length == 0) {
            return null;
        }
        // #3 하나라도 사설/예약이면 거부 (전부 공인이어야 통과).
        for (InetAddress addr : addrs) {
            if (isBlockedAddress(addr)) {
                return null;
            }
        }

        // 원본 URL 을 그대로 connect 에 사용 (hostname 기반 → HTTPS SNI/인증서 검증 정상).
        return new ValidatedTarget(url, host);
    }

    /**
     * #1b 포트 allowlist 판정. OG/피드 대상=웹서버이므로 표준 웹 포트({@code 80,443,8080,8443})만
     * 허용한다. 로컬 HttpServer 가 ephemeral 포트를 쓰는 테스트는 {@code allowLoopbackForTest}
     * 훅과 함께 이 메서드를 override 해 임의 포트를 허용한다 — <b>운영 기본은 strict allowlist.</b>
     */
    protected boolean isAllowedPort(int port) {
        return ALLOWED_PORTS.contains(port);
    }

    /**
     * #4 connect 직전 재검증 (DNS rebinding 방어 유지). host 를 다시 resolve 해 전 IP 가
     * 여전히 공인인지 확인한다. 하나라도 사설/예약으로 바뀌었으면 거부.
     */
    private boolean revalidatePeer(String host) {
        InetAddress[] addrs;
        try {
            addrs = resolve(host);
        } catch (UnknownHostException e) {
            return false;
        }
        if (addrs.length == 0) {
            return false;
        }
        for (InetAddress addr : addrs) {
            if (isBlockedAddress(addr)) {
                return false;
            }
        }
        return true;
    }

    /**
     * #3 차단 대역 판정.
     *
     * <p>차단: loopback/any-local/link-local/site-local/multicast, 메타데이터 IP,
     * IPv4-mapped IPv6, unique-local IPv6(fc00::/7).
     *
     * <p>fix1 결함2 추가: CGNAT 100.64.0.0/10, class-E 240.0.0.0/4, benchmarking
     * 198.18.0.0/15·192.0.0.0/24, broadcast 255.255.255.255, NAT64 64:ff9b::/96(임베드 v4 재검증),
     * 6to4 2002::/16(임베드 v4 재검증).
     *
     * <p>테스트에서 size/timeout/Content-Type 검증 경로를 로컬 서버로 실측하기 위해
     * {@link #allowLoopbackForTest} 훅으로만 loopback 통과를 허용한다 — 기본 false(운영 차단).
     */
    protected boolean isBlockedAddress(InetAddress addr) {
        if (addr == null) {
            return true;
        }

        // IPv6 별도 처리: IPv4-mapped, NAT64, 6to4 는 내장 IPv4 추출 후 재검사.
        if (addr instanceof Inet6Address ipv6) {
            byte[] raw = ipv6.getAddress();

            // IPv4-mapped IPv6(::ffff:a.b.c.d): 내장 IPv4 를 추출해 재검사.
            if (isIpv4Mapped(raw)) {
                return checkEmbeddedV4(raw, 12);
            }
            // NAT64 64:ff9b::/96 — 0064:ff9b:0:0:0:0:: prefix, 내장 IPv4 는 마지막 4바이트.
            if (isNat64(raw)) {
                return checkEmbeddedV4(raw, 12);
            }
            // 6to4 2002::/16 — 다음 4바이트(2..5)가 캡슐화 IPv4.
            if ((raw[0] & 0xFF) == 0x20 && (raw[1] & 0xFF) == 0x02) {
                return checkEmbeddedV4(raw, 2);
            }
            // unique-local IPv6 fc00::/7 (첫 바이트 0xFC 또는 0xFD).
            int first = raw[0] & 0xFF;
            if (first == 0xFC || first == 0xFD) {
                return true;
            }
        }

        if (addr.isLoopbackAddress()) {
            return !allowLoopbackForTest;
        }
        if (addr.isAnyLocalAddress()
                || addr.isLinkLocalAddress()
                || addr.isSiteLocalAddress()
                || addr.isMulticastAddress()) {
            return true;
        }

        // 메타데이터 엔드포인트 명시 차단(link-local 로 이미 잡히나 이중 안전장치).
        if (METADATA_IPV4.equals(addr.getHostAddress())) {
            return true;
        }

        // fix1 결함2: IPv4 추가 예약 대역 (isSiteLocalAddress 등으로 안 잡히는 갭).
        if (addr instanceof Inet4Address ipv4) {
            if (isBlockedV4Range(ipv4.getAddress())) {
                return true;
            }
        }

        return false;
    }

    /**
     * IPv6 내부 임베드 IPv4(offset 시작 4바이트)를 추출해 IPv4 차단 판정으로 위임한다.
     */
    private boolean checkEmbeddedV4(byte[] raw, int offset) {
        byte[] v4 = new byte[] { raw[offset], raw[offset + 1], raw[offset + 2], raw[offset + 3] };
        try {
            return isBlockedAddress(InetAddress.getByAddress(v4));
        } catch (UnknownHostException e) {
            return true;
        }
    }

    /**
     * fix1 결함2: JDK 표준 판정(loopback/site-local/link-local/multicast)으로 안 잡히는
     * IPv4 예약 대역을 추가 차단한다.
     */
    private static boolean isBlockedV4Range(byte[] b) {
        int o0 = b[0] & 0xFF;
        int o1 = b[1] & 0xFF;
        int o2 = b[2] & 0xFF;
        int o3 = b[3] & 0xFF;

        // CGNAT 100.64.0.0/10 (MED) — isSiteLocalAddress=false 라 누락됨.
        if (o0 == 100 && o1 >= 64 && o1 <= 127) {
            return true;
        }
        // class-E 240.0.0.0/4 (LOW).
        if (o0 >= 240) {
            return true;
        }
        // benchmarking 198.18.0.0/15 (LOW).
        if (o0 == 198 && (o1 == 18 || o1 == 19)) {
            return true;
        }
        // IETF protocol assignments 192.0.0.0/24 (LOW).
        if (o0 == 192 && o1 == 0 && o2 == 0) {
            return true;
        }
        // limited broadcast 255.255.255.255 (LOW).
        if (o0 == 255 && o1 == 255 && o2 == 255 && o3 == 255) {
            return true;
        }
        return false;
    }

    private static boolean isIpv4Mapped(byte[] raw) {
        if (raw.length != 16) {
            return false;
        }
        for (int i = 0; i < 10; i++) {
            if (raw[i] != 0) {
                return false;
            }
        }
        return (raw[10] & 0xFF) == 0xFF && (raw[11] & 0xFF) == 0xFF;
    }

    /**
     * NAT64 well-known prefix 64:ff9b::/96 판정. 첫 12바이트가 00 64 ff 9b 00..00.
     */
    private static boolean isNat64(byte[] raw) {
        if (raw.length != 16) {
            return false;
        }
        if ((raw[0] & 0xFF) != 0x00 || (raw[1] & 0xFF) != 0x64
                || (raw[2] & 0xFF) != 0xFF || (raw[3] & 0xFF) != 0x9B) {
            return false;
        }
        for (int i = 4; i < 12; i++) {
            if (raw[i] != 0) {
                return false;
            }
        }
        return true;
    }

    /**
     * 테스트가 host→IP 매핑을 주입할 수 있도록 분리한 resolve 지점.
     * 운영에서는 시스템 DNS 를 그대로 사용한다.
     */
    protected InetAddress[] resolve(String host) throws UnknownHostException {
        return InetAddress.getAllByName(host);
    }

    /**
     * 보안 우회 테스트 훅: loopback 주소 차단을 무력화한다. <b>테스트 전용, 기본 false.</b>
     * 운영 코드에서는 절대 true 로 설정하지 않는다.
     */
    protected boolean allowLoopbackForTest = false;

    private static boolean isRedirect(int status) {
        return status == 301 || status == 302 || status == 303 || status == 307 || status == 308;
    }

    /**
     * "text/html; charset=utf-8" → "text/html". 파라미터 제거 후 소문자/trim.
     */
    private static String mediaType(String contentType) {
        if (contentType == null) {
            return "";
        }
        int semi = contentType.indexOf(';');
        String base = semi >= 0 ? contentType.substring(0, semi) : contentType;
        return base.trim().toLowerCase(Locale.ROOT);
    }

    /**
     * #6 InputStream 에서 maxBytes 까지만 읽는다. 초과 시 null(거부). Content-Length 무시.
     */
    private static byte[] readCapped(InputStream in, long maxBytes) throws IOException {
        try (in) {
            java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
            byte[] buf = new byte[8192];
            long total = 0;
            int n;
            while ((n = in.read(buf)) != -1) {
                total += n;
                if (total > maxBytes) {
                    return null;
                }
                out.write(buf, 0, n);
            }
            return out.toByteArray();
        }
    }

    private static void drain(InputStream in) {
        try (in) {
            in.readAllBytes();
        } catch (IOException ignored) {
            // best-effort close; 차단/리다이렉트 경로에서 본문은 버린다.
        }
    }

    /** 검증을 통과한 connect 대상. requestUri 는 원본 host 기반(HTTPS SNI/인증서 정상). */
    private record ValidatedTarget(URI requestUri, String host) {
    }
}
