package cloud.nstart.terravault.ordinis.outbox.webhook;

import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent;
import cloud.nstart.terravault.ordinis.domain.outbox.OutboxEventRepository;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDelivery;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookDeliveryRepository;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscription;
import cloud.nstart.terravault.ordinis.domain.webhook.WebhookSubscriptionRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;

/**
 * Webhook dispatcher: HTTP callback delivery параллельно с Kafka.
 *
 * <p>Два @Scheduled tick'а:
 * <ol>
 *   <li><b>matchTick</b>: scan recent unpublished outbox_events × active
 *       subscriptions. INSERT webhook_deliveries (idempotent ON CONFLICT) для
 *       каждого match. Пишет PENDING rows.
 *   <li><b>sendTick</b>: scan due deliveries (status pending/retrying AND
 *       next_attempt_at &lt;= now). Загружает event payload, signs HMAC, POSTs
 *       URL. Mark delivered (2xx) / retrying (backoff) / dlq (после 1000 attempts).
 * </ol>
 *
 * <p>Активируется только при {@code ordinis.webhook.enabled=true}.
 */
@Component
@ConditionalOnProperty(name = "ordinis.webhook.enabled", havingValue = "true", matchIfMissing = false)
public class WebhookDispatcher {

  private static final Logger log = LoggerFactory.getLogger(WebhookDispatcher.class);

  private final OutboxEventRepository outboxRepository;
  private final WebhookSubscriptionRepository subscriptionRepository;
  private final WebhookDeliveryRepository deliveryRepository;
  private final ObjectMapper objectMapper;
  private final HttpClient httpClient;
  private SsrfGuard ssrfGuard;

  private final Counter deliveredCounter;
  private final Counter failedCounter;
  private final Counter dlqCounter;
  private final Counter ssrfRejectedCounter;
  private final Timer deliveryTimer;

  @Value("${ordinis.webhook.batch-size:50}")
  private int batchSize;

  @Value("${ordinis.webhook.send-timeout-ms:5000}")
  private long sendTimeoutMs;

  @Value("${ordinis.webhook.allowed-hosts:}")
  private String allowedHostsCsv;

  public WebhookDispatcher(
      OutboxEventRepository outboxRepository,
      WebhookSubscriptionRepository subscriptionRepository,
      WebhookDeliveryRepository deliveryRepository,
      ObjectMapper objectMapper,
      MeterRegistry meterRegistry) {
    this.outboxRepository = outboxRepository;
    this.subscriptionRepository = subscriptionRepository;
    this.deliveryRepository = deliveryRepository;
    this.objectMapper = objectMapper;
    this.httpClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(3))
        .build();

    this.deliveredCounter = Counter.builder("nsi_webhook_delivered_total")
        .description("Successful webhook deliveries (2xx response)")
        .register(meterRegistry);
    this.failedCounter = Counter.builder("nsi_webhook_failed_total")
        .description("Failed webhook delivery attempts (will retry)")
        .register(meterRegistry);
    this.dlqCounter = Counter.builder("nsi_webhook_dlq_total")
        .description("Webhook deliveries moved to DLQ (attempt cap reached)")
        .register(meterRegistry);
    this.ssrfRejectedCounter = Counter.builder("nsi_webhook_ssrf_rejected_total")
        .description("Webhook URLs rejected by SSRF guard")
        .register(meterRegistry);
    this.deliveryTimer = Timer.builder("nsi_webhook_delivery_duration_seconds")
        .description("HTTP POST duration for webhook delivery")
        .register(meterRegistry);
  }

  @PostConstruct
  void initSsrfGuard() {
    List<String> allowed = allowedHostsCsv == null || allowedHostsCsv.isBlank()
        ? List.of()
        : Arrays.stream(allowedHostsCsv.split(",")).map(String::trim).toList();
    this.ssrfGuard = new SsrfGuard(allowed);
    log.info("WebhookDispatcher initialized. Allowed hosts: {}", allowed);
  }

  // === Tick 1: matcher ===

  /**
   * Scan recent unpublished events × active subscriptions, INSERT pending
   * deliveries. Idempotent: ON CONFLICT (subscription_id, outbox_event_id)
   * DO NOTHING.
   *
   * <p>Note: WebhookDispatcher работает по same outbox_events что и
   * Kafka publisher. Event попадает в дисптчер до того как Kafka publisher
   * marks published_at — это нормально, мы matching по существованию
   * row, не по published status.
   */
  @Scheduled(fixedDelayString = "${ordinis.webhook.match-interval-ms:2000}")
  @Transactional
  public void matchTick() {
    List<WebhookSubscription> subs = subscriptionRepository.findActive();
    if (subs.isEmpty()) return;

    // Берём recent events (последние 1000 самых новых). Старше — уже либо
    // published в Kafka (значит и сюда match'нулись если был active sub
    // в момент создания), либо в DLQ. Поллер не нужен для catch-up.
    List<OutboxEvent> recent = outboxRepository
        .findUnpublished(PageRequest.of(0, 1000));

    for (OutboxEvent event : recent) {
      for (WebhookSubscription sub : subs) {
        if (matches(event, sub)) {
          deliveryRepository.insertIfAbsent(sub.getId(), event.getId());
        }
      }
    }
  }

  static boolean matches(OutboxEvent event, WebhookSubscription sub) {
    if (sub.getScopeFilter() != null && !sub.getScopeFilter().isEmpty()
        && !sub.getScopeFilter().contains(event.getDataScope())) {
      return false;
    }
    if (sub.getDictionaryFilter() != null && !sub.getDictionaryFilter().isEmpty()
        && (event.getDictionaryName() == null
            || !sub.getDictionaryFilter().contains(event.getDictionaryName()))) {
      return false;
    }
    if (sub.getEventTypeFilter() != null && !sub.getEventTypeFilter().isEmpty()
        && !sub.getEventTypeFilter().contains(event.getEventType())) {
      return false;
    }
    return true;
  }

  // === Tick 2: sender ===

  @Scheduled(fixedDelayString = "${ordinis.webhook.send-interval-ms:1000}")
  public void sendTick() {
    List<WebhookDelivery> due = deliveryRepository.findDue(
        OffsetDateTime.now(), PageRequest.of(0, batchSize));
    if (due.isEmpty()) return;

    log.debug("Webhook sendTick: {} due deliveries", due.size());

    for (WebhookDelivery delivery : due) {
      attemptDelivery(delivery);
    }
  }

  /**
   * Одна попытка доставки. @Transactional чтобы изменения delivery атомарны.
   * SELF-call через bean reference не делаем — каждая итерация в своей tx
   * через wrapper public method.
   */
  @Transactional
  void attemptDelivery(WebhookDelivery delivery) {
    Optional<WebhookSubscription> subOpt = subscriptionRepository.findById(delivery.getSubscriptionId());
    if (subOpt.isEmpty() || !subOpt.get().isActive()) {
      // Subscription deleted/disabled между match и send. Mark dlq чтобы не
      // тратить попытки впустую (admin может resetDlq если subscription
      // вернётся).
      delivery.markFailed("Subscription not found or inactive", null);
      deliveryRepository.save(delivery);
      return;
    }
    WebhookSubscription sub = subOpt.get();

    Optional<OutboxEvent> eventOpt = outboxRepository.findById(delivery.getOutboxEventId());
    if (eventOpt.isEmpty()) {
      delivery.markFailed("Outbox event not found", null);
      deliveryRepository.save(delivery);
      return;
    }
    OutboxEvent event = eventOpt.get();

    // SSRF guard: блокируем delivery на private CIDR.
    String ssrfReason = ssrfGuard.validate(sub.getUrl());
    if (ssrfReason != null) {
      ssrfRejectedCounter.increment();
      delivery.markFailed("SSRF guard: " + ssrfReason, null);
      if (delivery.isDlq()) dlqCounter.increment();
      deliveryRepository.save(delivery);
      return;
    }

    Timer.Sample sample = Timer.start();
    try {
      byte[] body = objectMapper.writeValueAsBytes(event.getPayload());
      String signature = HmacSigner.sign(sub.getHmacSecret(), body);

      HttpRequest req = HttpRequest.newBuilder()
          .uri(URI.create(sub.getUrl()))
          .timeout(Duration.ofMillis(sendTimeoutMs))
          .header("Content-Type", "application/json")
          .header("User-Agent", "Ordinis-Webhook/1.0")
          .header(HmacSigner.HEADER_NAME, signature)
          .header("X-Ordinis-Event-Id", String.valueOf(event.getId()))
          .header("X-Ordinis-Event-Type", event.getEventType())
          .header("X-Ordinis-Scope", event.getDataScope().name())
          .POST(BodyPublishers.ofByteArray(body))
          .build();

      HttpResponse<String> resp = httpClient.send(req, BodyHandlers.ofString(StandardCharsets.UTF_8));
      sample.stop(deliveryTimer);

      if (resp.statusCode() >= 200 && resp.statusCode() < 300) {
        delivery.markDelivered(resp.statusCode());
        deliveredCounter.increment();
      } else {
        // 4xx — клиент скорее всего не починится, но стандартный retry
        // (admin может deactivate subscription). 5xx — transient.
        String body2xx = resp.body() == null ? "" : resp.body();
        String error = "HTTP " + resp.statusCode() + ": "
            + body2xx.substring(0, Math.min(200, body2xx.length()));
        delivery.markFailed(error, resp.statusCode());
        failedCounter.increment();
        if (delivery.isDlq()) dlqCounter.increment();
      }
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      delivery.markFailed("Interrupted: " + e.getMessage(), null);
      failedCounter.increment();
    } catch (Exception e) {
      delivery.markFailed(
          e.getClass().getSimpleName() + ": " + e.getMessage(), null);
      failedCounter.increment();
      if (delivery.isDlq()) dlqCounter.increment();
    }
    deliveryRepository.save(delivery);
  }
}
