package study.gateway;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT, properties={
    "server.address=127.0.0.1", "spring.main.web-application-type=reactive",
    "spring.cloud.loadbalancer.cache.ttl=800ms",
    "spring.cloud.loadbalancer.subset.size=1",
    "spring.cloud.loadbalancer.health-check.interval=100ms",
    "spring.cloud.loadbalancer.health-check.update-results-list=false",
    "spring.cloud.gateway.server.webflux.httpclient.connect-timeout=300",
    "spring.cloud.gateway.server.webflux.httpclient.response-timeout=2s",
    "management.endpoints.web.exposure.include=gateway,metrics",
    "management.endpoint.gateway.access=read-only"
})
@DirtiesContext(classMode=DirtiesContext.ClassMode.AFTER_CLASS)
class GatewayHttpTest {
    @LocalServerPort int port;
    @Autowired Upstreams peers;
    @Autowired LabDirectory directory;
    @Autowired JwtMaterial jwt;
    @Autowired RateLimitResources rates;
    final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
    @org.junit.jupiter.api.AfterEach void closeClient() { client.close(); }
    HttpResponse<String> get(String path,String...headers) throws Exception {
        var request=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+path)).timeout(Duration.ofSeconds(5)).GET();
        if(headers.length>0)request.headers(headers);
        return client.send(request.build(),HttpResponse.BodyHandlers.ofString());
    }
    @Test void pathQueryAndRouteFilterReachRealUpstream() throws Exception {
        var result=get("/api/items/7?x=1");
        assertEquals(200,result.statusCode());
        assertTrue(result.body().contains("path=/items/7\nquery=x=1"));
        assertTrue(result.body().contains("label=first"));
        assertEquals(404,get("/not-a-route").statusCode());
        assertEquals(200,get("/api/items/8").statusCode());
        System.out.println("U32 pathQueryHeader=PASS unmatched404ThenRecovery200=true");
    }
    @Test void roundRobinEmptyDirectoryAndRecoveryAreDifferentFromHttpHealth() throws Exception {
        directory.set("round", peers.instances());
        var first=get("/lb/round/echo");
        var second=get("/lb/round/echo");
        assertEquals(200,first.statusCode());
        assertEquals(200,second.statusCode());
        assertNotEquals(first.body().split("\n")[0],second.body().split("\n")[0]);
        directory.set("round", java.util.List.of());
        assertEquals(503,get("/lb/round/echo").statusCode());
        directory.set("round", java.util.List.of(peers.instances().getFirst()));
        assertTrue(get("/lb/round/echo").body().startsWith("instance=A"));
        System.out.println("U32 directoryRoundRobin=PASS alternating2 empty503ThenA200=true");
    }
    @Test void cachedDirectoryRetainsOldInstanceUntilExplicitShortTtlExpires() throws Exception {
        var instances=peers.instances();
        directory.set("cached",java.util.List.of(instances.getFirst()));
        directory.set("direct",java.util.List.of(instances.getFirst()));
        assertTrue(get("/lb/cached/echo").body().startsWith("instance=A"));
        assertTrue(get("/lb/direct/echo").body().startsWith("instance=A"));
        directory.set("cached",java.util.List.of(instances.getLast()));
        directory.set("direct",java.util.List.of(instances.getLast()));
        assertTrue(get("/lb/direct/echo").body().startsWith("instance=B"));
        assertTrue(get("/lb/cached/echo").body().startsWith("instance=A"));
        long deadline=System.nanoTime()+Duration.ofSeconds(4).toNanos();
        String after="";
        while (System.nanoTime()<deadline) {
            after=get("/lb/cached/echo").body();
            if (after.startsWith("instance=B")) break;
            Thread.sleep(50);
        }
        assertTrue(after.startsWith("instance=B"),"cached directory must refresh within the configured short lab TTL");
        System.out.println("U32 directoryCache=PASS directB staleA thenRefreshedB=true labTtl800msNotDefault35s=true");
    }
    @Test void healthUsesHttp200NotJsonAndAllDownReturnsEmptyThenRecovers() throws Exception {
        directory.set("health",peers.instances());
        peers.health("A",503); peers.health("B",200);
        try {
            expectEventually(200,"instance=B");
            peers.health("B",503);
            expectEventually(503,"");
            peers.health("A",200);
            expectEventually(200,"instance=A"); // JSON body remains DOWN, status alone is checked.
            peers.health("A",204);
            expectEventually(503,""); // 204 is not the exact 200 accepted by this supplier.
            peers.health("A",200);
            expectEventually(200,"instance=A");
            System.out.println("U32 health=PASS oneUpB allDown503 bodyDown200A accepted204False recoveryA200=true");
        } finally { peers.health("A",200); peers.health("B",200); }
    }
    void expectEventually(int status,String prefix) throws Exception {
        long deadline=System.nanoTime()+Duration.ofSeconds(4).toNanos();
        HttpResponse<String> response=null;
        while(System.nanoTime()<deadline) {
            response=get("/lb/health/echo");
            if(response.statusCode()==status&&response.body().startsWith(prefix))return;
            Thread.sleep(60);
        }
        fail("expected health "+status+" "+prefix+" but last="+(response==null?"none":response.statusCode()+" "+response.body()));
    }

    @Test void responseTimeoutStopsSlowHeadersButDoesNotBoundEntireBody() throws Exception {
        assertEquals(504,get("/timeout/head").statusCode());
        long start=System.nanoTime();
        var streamed=get("/timeout/body");
        long elapsed=Duration.ofNanos(System.nanoTime()-start).toMillis();
        assertEquals(200,streamed.statusCode()); assertEquals("start-end",streamed.body());
        assertTrue(elapsed>=300,"body remains readable after the 100ms header response timeout");
        assertEquals(200,get("/api/recovered").statusCode());
        System.out.println("U32 timeout=PASS slowHeader504 streamedBody200 elapsedMs="+elapsed+" route100msNotWholeBodyDeadline=true recovery200=true");
    }

    @Test void gatewayAuthenticatesBeforeForwardingAndDownstreamChecksBearerAgain() throws Exception {
        assertEquals(401,get("/secure/identity").statusCode());
        for(String bad:java.util.List.of("signature","issuer","audience","expired")) {
            assertEquals(401,get("/secure/identity","Authorization","Bearer "+jwt.token(bad)).statusCode(),bad);
            assertEquals(200,get("/secure/identity","Authorization","Bearer "+jwt.token("ok")).statusCode());
        }
        assertEquals(403,get("/secure/identity","Authorization","Bearer "+jwt.token("scope")).statusCode());
        var valid=get("/secure/identity","Authorization","Bearer "+jwt.token("ok"),"X-User","mallory","X-Verified-User","bob");
        assertEquals(200,valid.statusCode()); assertTrue(valid.body().contains("subject=alice"));
        assertTrue(valid.body().contains("user=null")); assertTrue(valid.body().contains("verified=alice"));
        var direct=client.send(HttpRequest.newBuilder(URI.create(peers.uri()+"/identity"))
                .header("X-Verified-User","alice").GET().build(),HttpResponse.BodyHandlers.ofString());
        assertEquals(401,direct.statusCode(),"identity header cannot bypass the downstream bearer validator");
        System.out.println("U32 identity=PASS anonBadSignatureIssuerAudienceExpired401 scope403 forgedHeadersRemoved downstreamBearerRequired recovery200=true");
    }

    @Test void configuredThreeRetriesMeanFourHttpAttempts() throws Exception {
        var retried=get("/retry/always");
        assertEquals(503,retried.statusCode()); assertTrue(retried.body().contains("attempt=4"));
        var once=get("/no-retry");
        assertEquals(503,once.statusCode()); assertTrue(once.body().contains("attempt=1"));
        var posted=client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/retry/post-only"))
                .timeout(Duration.ofSeconds(5))
                .POST(HttpRequest.BodyPublishers.ofString("payload")).build(),HttpResponse.BodyHandlers.ofString());
        assertEquals(503,posted.statusCode());
        assertTrue(posted.body().contains("attempt=1"),"default retry filter must not retry POST");
        assertEquals(200,get("/api/after-retry").statusCode());
        System.out.println("U32 retry=PASS configured3 finalAttempt4 noRetryAttempt1 postAttempt1=true recovery200=true");
    }
    @Test void redisQuotaIsPerPrincipalEmptyKeyDeniesAndBrokenLinkFailsOpenThenRecovers() throws Exception {
        try {
        var response=get("/limited/echo","Authorization","Bearer "+jwt.token("ok"));
        assertEquals(200,response.statusCode());
        assertEquals(429,get("/limited/echo","Authorization","Bearer "+jwt.token("ok")).statusCode());
        assertEquals(200,get("/limited/echo","Authorization","Bearer "+jwt.token("bob")).statusCode());
        assertEquals(403,get("/empty-key").statusCode());
        rates.link.fail(true);
        var allowed=get("/limited/echo","Authorization","Bearer "+jwt.token("ok"));
        assertEquals(200,allowed.statusCode());
        assertEquals("-1",allowed.headers().firstValue("X-RateLimit-Remaining").orElseThrow());
        var carolOutage=get("/limited/echo","Authorization","Bearer "+jwt.token("carol"));
        assertEquals(200,carolOutage.statusCode());
        assertEquals("-1",carolOutage.headers().firstValue("X-RateLimit-Remaining").orElseThrow());
        rates.link.fail(false);
        // carol never spent tokens before the outage. Wait out any in-flight
        // fail-open response from the reconnecting link, then require a real
        // (non -1) allowance followed by 429: the failed calls deducted nothing.
        int carolStatus=0; String carolRemaining="";
        long carolDeadline=System.nanoTime()+Duration.ofSeconds(4).toNanos();
        while(System.nanoTime()<carolDeadline) {
            var carol=get("/limited/echo","Authorization","Bearer "+jwt.token("carol"));
            carolStatus=carol.statusCode();
            carolRemaining=carol.headers().firstValue("X-RateLimit-Remaining").orElse("");
            if(carolStatus==200&&!"-1".equals(carolRemaining))break;
            Thread.sleep(60);
        }
        assertEquals(200,carolStatus,"carol must be allowed from a full bucket");
        assertNotEquals("-1",carolRemaining,"recovery must reach the real limiter");
        assertEquals(429,get("/limited/echo","Authorization","Bearer "+jwt.token("carol")).statusCode());
        long deadline=System.nanoTime()+Duration.ofSeconds(6).toNanos();
        int status=0;
        while(System.nanoTime()<deadline) {
            status=get("/limited/echo","Authorization","Bearer "+jwt.token("ok")).statusCode();
            if(status==429)break;
            Thread.sleep(80);
        }
        assertEquals(429,status,"restored Redis link must enforce the still-depleted quota");
        System.out.println("U32 redisQuota=PASS alice200Then429 bob200 empty403 errorAllows200RemainingMinus1 carolFullAfterRecovery=true recovery429=true");
        } finally { rates.link.fail(false); rates.cleanupKeys(); }
    }

    @Test void requestHintsZonesStickyAndWeightsHaveDocumentedFallbacks() throws Exception {
        var instances=peers.instances();
        for(String id:java.util.List.of("zone","hint","sticky","weighted","subset"))directory.set(id,instances);
        assertTrue(get("/lb/zone/echo").body().startsWith("instance=A"));
        instances.getFirst().getMetadata().put("zone","west");
        String zoneA=get("/lb/zone/echo").body().split("\n")[0];
        String zoneB=get("/lb/zone/echo").body().split("\n")[0];
        assertNotEquals(zoneA,zoneB,"no east zone falls back to both instances");
        assertTrue(get("/lb/hint/echo","X-SC-LB-Hint","blue").body().startsWith("instance=A"));
        assertTrue(get("/lb/hint/echo","X-SC-LB-Hint","green").body().startsWith("instance=B"));
        assertNotEquals(get("/lb/hint/echo","X-SC-LB-Hint","absent").body().split("\n")[0],
                get("/lb/hint/echo","X-SC-LB-Hint","absent").body().split("\n")[0]);
        assertTrue(get("/lb/sticky/echo","Cookie","sc-lb-instance-id=B").body().startsWith("instance=B"));
        assertEquals(200,get("/lb/sticky/echo","Cookie","sc-lb-instance-id=missing").statusCode());
        int a=0,b=0;
        for(int n=0;n<8;n++) { String body=get("/lb/weighted/echo").body(); if(body.startsWith("instance=A"))a++; if(body.startsWith("instance=B"))b++; }
        assertEquals(2,a); assertEquals(6,b);
        instances.getFirst().getMetadata().put("weight","0"); instances.getLast().getMetadata().put("weight","1");
        a=0; for(int n=0;n<4;n++)if(get("/lb/weighted/echo").body().startsWith("instance=A"))a++;
        assertEquals(2,a,"zero weight falls back to one, it does not deregister A");
        String subset=get("/lb/subset/echo").body().split("\n")[0];
        for(int n=0;n<5;n++)assertEquals(subset,get("/lb/subset/echo").body().split("\n")[0]);
        System.out.println("U32 supplierPolicies=PASS zoneAndHintFallback stickyB unknownCookieFallback weighted2A6B zeroWeightStillA subsetSize1Stable=true");
    }
    @Test void untrustedForwardedHeadersAreRemovedBeforeRealUpstream() throws Exception {
        var result=get("/api/forwarded","X-Forwarded-For","203.0.113.9","Forwarded","for=203.0.113.9;proto=https");
        assertEquals(200,result.statusCode());
        assertTrue(result.body().contains("xff=null")); assertTrue(result.body().contains("forwarded=null"));
        assertEquals(200,get("/api/after-forwarded").statusCode());
        System.out.println("U32 untrustedForwarded=PASS forgedXffAndForwardedRemoved=true recovery200=true");
    }

    @Test void globalAndRouteFiltersMergeByOrderAndUnwindBeforeWriting() throws Exception {
        var result=get("/api/ordered");
        assertEquals(200,result.statusCode());
        assertTrue(result.body().contains("pre=G0-pre>R10-pre>G20-pre>R30-pre"));
        assertEquals("G0-pre>R10-pre>G20-pre>R30-pre>R30-post>G20-post>R10-post>G0-post",
                result.headers().firstValue("X-Lab-Trace").orElseThrow());
        System.out.println("U32 filterOrder=PASS mergedPreG0R10G20R30 reversePostR30G20R10G0=true");
    }

    @Test void managementReadAccessRequiresOpsAndGatewayMetricCountsTheRoute() throws Exception {
        assertEquals(401,get("/actuator/gateway/routes").statusCode());
        assertEquals(403,get("/actuator/gateway/routes","Authorization","Bearer "+jwt.token("ok")).statusCode());
        var routes=get("/actuator/gateway/routes","Authorization","Bearer "+jwt.token("ops"));
        assertEquals(200,routes.statusCode()); assertTrue(routes.body().contains("first"));
        double before=routeCount();
        assertEquals(200,get("/api/one-measured-request").statusCode());
        long deadline=System.nanoTime()+Duration.ofSeconds(2).toNanos(); double after=before;
        while(System.nanoTime()<deadline) { after=routeCount(); if(after==before+1)break;Thread.sleep(20); }
        assertEquals(before+1,after);
        var write=client.send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+port+"/actuator/gateway/refresh"))
                .header("Authorization","Bearer "+jwt.token("ops")).POST(HttpRequest.BodyPublishers.noBody()).build(),
                HttpResponse.BodyHandlers.ofString());
        // read-only removes write operations from the mapping, so the path is simply unmapped for POST.
        assertEquals(404,write.statusCode(),"read-only access must not expose the write operation");
        System.out.println("U32 managementMetrics=PASS anonymous401 read403 opsRead200 firstRouteCountPlusOne=true readonlyPostRefresh404=true");
    }
    double routeCount() throws Exception {
        var metric=get("/actuator/metrics/spring.cloud.gateway.requests?tag=routeId:first","Authorization","Bearer "+jwt.token("ops"));
        if(metric.statusCode()==404)return 0;
        assertEquals(200,metric.statusCode());
        var data=tools.jackson.databind.json.JsonMapper.builder().build().readTree(metric.body());
        for(var measurement:data.get("measurements"))if("COUNT".equals(measurement.get("statistic").asString()))return measurement.get("value").asDouble();
        throw new AssertionError("missing count measurement");
    }

}
