package cloud.nstart.terravault.ordinis.restapi.service.webhook;

import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
import cloud.nstart.terravault.ordinis.outbox.webhook.HmacSigner;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;

import static org.assertj.core.api.Assertions.assertThat;

/**
 * Unit-тесты {@link WebhookTestPingService}: SSRF reject, success/failure
 * paths, network exception, body trim, HMAC signature header.
 *
 * <p>HttpClient не мокается напрямую (Mockito inline-mock не поддерживает
 * sealed класс {@link HttpClient} на Java 17+). Вместо этого тесты создают
 * анонимный subclass сервиса и переопределяют {@code sendRequest()}.
 */
class WebhookTestPingServiceTest {

  private static final String SECRET = "deadbeefcafebabe1234567890abcdef00112233445566778899aabbccddeeff";
  private static final String ALLOWED_URL = "https://hooks.example.test/webhook";
  private static final String ALLOWED_HOST = "hooks.example.test";

  private final ObjectMapper objectMapper = new ObjectMapper();
  // Сам HttpClient не используется в тестах — все вызовы перехватываются в sendRequest().
  private final HttpClient unusedClient = HttpClient.newHttpClient();

  @Test
  void rejected_whenUrlIsPrivateCidr() {
    AtomicReference<HttpRequest> sent = new AtomicReference<>();
    WebhookTestPingService service = stubbedService(
        List.of(),
        req -> { sent.set(req); return stubResponse(200, "should not be called"); });
    // 10.255.255.255 — IPv4 literal в RFC 1918 диапазоне. SsrfGuard.isPrivate
    // вернёт true (isSiteLocalAddress), DNS lookup не нужен → не flake'ит CI.
    WebhookSubscription sub = newSubscription("http://10.255.255.255/hook");

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("rejected");
    assertThat(result.message()).contains("SSRF").contains("private");
    assertThat(result.httpStatus()).isNull();
    assertThat(result.latencyMs()).isNull();
    assertThat(sent.get()).as("sendRequest должен НЕ вызываться при SSRF reject").isNull();
  }

  @Test
  void rejected_whenSchemeIsNotHttp() {
    AtomicReference<HttpRequest> sent = new AtomicReference<>();
    WebhookTestPingService service = stubbedService(
        List.of(),
        req -> { sent.set(req); return stubResponse(200, ""); });
    WebhookSubscription sub = newSubscription("ftp://example.com/hook");

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("rejected");
    assertThat(result.message()).contains("scheme").contains("ftp");
    assertThat(sent.get()).isNull();
  }

  @Test
  void success_when2xxResponse() {
    WebhookTestPingService service = stubbedService(
        List.of(ALLOWED_HOST),
        req -> stubResponse(200, "{\"ok\":true}"));
    WebhookSubscription sub = newSubscription(ALLOWED_URL);

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("success");
    assertThat(result.httpStatus()).isEqualTo(200);
    assertThat(result.latencyMs()).isNotNull().isGreaterThanOrEqualTo(0L);
    assertThat(result.message()).isEqualTo("{\"ok\":true}");
  }

  @Test
  void failure_when5xxResponse() {
    WebhookTestPingService service = stubbedService(
        List.of(ALLOWED_HOST),
        req -> stubResponse(503, "service unavailable"));
    WebhookSubscription sub = newSubscription(ALLOWED_URL);

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("failure");
    assertThat(result.httpStatus()).isEqualTo(503);
    assertThat(result.message()).contains("HTTP 503").contains("service unavailable");
  }

  @Test
  void failure_whenNetworkException() {
    WebhookTestPingService service = stubbedService(
        List.of(ALLOWED_HOST),
        req -> { throw new IOException("connection refused"); });
    WebhookSubscription sub = newSubscription(ALLOWED_URL);

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("failure");
    assertThat(result.httpStatus()).isNull();
    assertThat(result.latencyMs()).isNotNull();
    assertThat(result.message()).contains("IOException").contains("connection refused");
  }

  @Test
  void responseBodyIsTruncated_whenLongerThan200Chars() {
    WebhookTestPingService service = stubbedService(
        List.of(ALLOWED_HOST),
        req -> stubResponse(200, "x".repeat(500)));
    WebhookSubscription sub = newSubscription(ALLOWED_URL);

    WebhookTestPingService.Result result = service.testPing(sub);

    assertThat(result.status()).isEqualTo("success");
    assertThat(result.message()).hasSize(201).endsWith("…");
  }

  @Test
  void requestHasHmacSignatureAndOrdinisHeaders() {
    AtomicReference<HttpRequest> sent = new AtomicReference<>();
    WebhookTestPingService service = stubbedService(
        List.of(ALLOWED_HOST),
        req -> { sent.set(req); return stubResponse(200, "ok"); });
    WebhookSubscription sub = newSubscription(ALLOWED_URL);

    service.testPing(sub);

    HttpRequest req = sent.get();
    assertThat(req).isNotNull();
    assertThat(req.method()).isEqualTo("POST");
    assertThat(req.uri().toString()).isEqualTo(ALLOWED_URL);

    Map<String, List<String>> headers = req.headers().map();
    assertThat(headers).containsKeys(
        HmacSigner.HEADER_NAME,
        "Content-Type",
        "X-Ordinis-Event-Id",
        "X-Ordinis-Event-Type",
        "X-Ordinis-Scope",
        "X-Ordinis-Test");
    assertThat(headers.get("Content-Type")).containsExactly("application/json");
    assertThat(headers.get("X-Ordinis-Event-Id")).containsExactly("test-ping");
    assertThat(headers.get("X-Ordinis-Event-Type")).containsExactly("test.ping");
    assertThat(headers.get("X-Ordinis-Test")).containsExactly("true");

    String signature = headers.get(HmacSigner.HEADER_NAME).get(0);
    assertThat(signature).matches("sha256=[0-9a-f]{64}");
  }

  // ---------- helpers ----------

  /** Lambda которая решает, что вернуть на захваченный HttpRequest. */
  @FunctionalInterface
  private interface RequestHandler {
    HttpResponse<String> handle(HttpRequest req) throws Exception;
  }

  /**
   * Build subclass с inline переопределённым sendRequest() — обходит
   * Mockito ограничение на mock'ание HttpClient на Java 17+.
   */
  private WebhookTestPingService stubbedService(List<String> allowed, RequestHandler handler) {
    return new WebhookTestPingService(objectMapper, allowed, unusedClient) {
      @Override
      protected HttpResponse<String> sendRequest(HttpRequest req) throws Exception {
        return handler.handle(req);
      }
    };
  }

  private static WebhookSubscription newSubscription(String url) {
    return new WebhookSubscription("test-sub", url, SECRET, "tester");
  }

  /**
   * Lightweight {@link HttpResponse} stub без Mockito — реализуем только
   * вызываемые сервисом методы ({@link HttpResponse#statusCode()} и
   * {@link HttpResponse#body()}). Остальное → {@link UnsupportedOperationException}.
   */
  private static HttpResponse<String> stubResponse(int statusCode, String body) {
    return new HttpResponse<>() {
      @Override public int statusCode() { return statusCode; }
      @Override public String body() { return body; }
      @Override public HttpRequest request() { throw new UnsupportedOperationException(); }
      @Override public java.util.Optional<HttpResponse<String>> previousResponse() { return java.util.Optional.empty(); }
      @Override public java.net.http.HttpHeaders headers() {
        return java.net.http.HttpHeaders.of(Map.of(), (a, b) -> true);
      }
      @Override public java.util.Optional<javax.net.ssl.SSLSession> sslSession() { return java.util.Optional.empty(); }
      @Override public java.net.URI uri() { throw new UnsupportedOperationException(); }
      @Override public HttpClient.Version version() { return HttpClient.Version.HTTP_1_1; }
    };
  }
}
