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

import cloud.nstart.terravault.ordinis.auth.ScopeContext;
import cloud.nstart.terravault.ordinis.domain.DataScope;
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 cloud.nstart.terravault.ordinis.restapi.dto.webhook.CreateWebhookSubscriptionRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookDeliveryStatsResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.webhook.WebhookSubscriptionResponse;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookSubscriptionService;
import cloud.nstart.terravault.ordinis.restapi.service.webhook.WebhookTestPingService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/**
 * Webhook subscriptions admin API. RBAC: only RESTRICTED scope can mutate
 * (create/update/delete/rotate-secret), INTERNAL+ может listing'и читать.
 *
 * <p>Endpoints:
 * <pre>
 *   GET    /api/v1/admin/webhooks/subscriptions       — list
 *   POST   /api/v1/admin/webhooks/subscriptions       — create (returns plaintext secret)
 *   GET    /api/v1/admin/webhooks/subscriptions/{id}  — get one
 *   PUT    /api/v1/admin/webhooks/subscriptions/{id}  — update
 *   DELETE /api/v1/admin/webhooks/subscriptions/{id}  — delete
 *   POST   /api/v1/admin/webhooks/subscriptions/{id}/rotate-secret
 *   GET    /api/v1/admin/webhooks/subscriptions/{id}/deliveries
 *   GET    /api/v1/admin/webhooks/deliveries/dlq
 * </pre>
 */
@RestController
@RequestMapping("/api/v1/admin/webhooks")
public class WebhookSubscriptionController {

  private final WebhookSubscriptionService service;
  private final WebhookSubscriptionRepository subscriptionRepository;
  private final WebhookDeliveryRepository deliveryRepository;
  private final WebhookTestPingService testPingService;
  private final ScopeContext scopeContext;

  public WebhookSubscriptionController(
      WebhookSubscriptionService service,
      WebhookSubscriptionRepository subscriptionRepository,
      WebhookDeliveryRepository deliveryRepository,
      WebhookTestPingService testPingService,
      ScopeContext scopeContext) {
    this.service = service;
    this.subscriptionRepository = subscriptionRepository;
    this.deliveryRepository = deliveryRepository;
    this.testPingService = testPingService;
    this.scopeContext = scopeContext;
  }

  // === Subscriptions CRUD ===

  @GetMapping("/subscriptions")
  public List<WebhookSubscriptionResponse> list() {
    requireInternal();
    return subscriptionRepository.findAll().stream()
        .map(WebhookSubscriptionResponse::from)
        .toList();
  }

  @GetMapping("/subscriptions/{id}")
  public WebhookSubscriptionResponse get(@PathVariable UUID id) {
    requireInternal();
    WebhookSubscription sub = service.findById(id);
    return WebhookSubscriptionResponse.from(sub);
  }

  @PostMapping("/subscriptions")
  @ResponseStatus(HttpStatus.CREATED)
  public WebhookSubscriptionResponse create(
      @Valid @RequestBody CreateWebhookSubscriptionRequest req) {
    requireAdmin();
    WebhookSubscriptionService.CreateResult result = service.create(req, currentUserId());
    // Plaintext secret виден ТОЛЬКО здесь — caller должен сохранить.
    return WebhookSubscriptionResponse.fromWithSecret(result.subscription(), result.plaintextSecret());
  }

  @PutMapping("/subscriptions/{id}")
  public WebhookSubscriptionResponse update(
      @PathVariable UUID id,
      @Valid @RequestBody CreateWebhookSubscriptionRequest req) {
    requireAdmin();
    return WebhookSubscriptionResponse.from(service.update(id, req));
  }

  @DeleteMapping("/subscriptions/{id}")
  @ResponseStatus(HttpStatus.NO_CONTENT)
  public void delete(@PathVariable UUID id) {
    requireAdmin();
    service.delete(id);
  }

  @PostMapping("/subscriptions/{id}/rotate-secret")
  public WebhookSubscriptionResponse rotateSecret(@PathVariable UUID id) {
    requireAdmin();
    WebhookSubscriptionService.CreateResult result = service.rotateSecret(id);
    return WebhookSubscriptionResponse.fromWithSecret(result.subscription(), result.plaintextSecret());
  }

  // === Delivery history ===

  @GetMapping("/subscriptions/{id}/deliveries")
  public Page<WebhookDeliveryResponse> deliveries(
      @PathVariable UUID id,
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "50") int size,
      @RequestParam(value = "status", required = false) String statusFilter) {
    requireInternal();
    PageRequest pr = PageRequest.of(page, Math.min(size, 200));
    Page<WebhookDelivery> deliveries;
    if (statusFilter != null && !statusFilter.isBlank()) {
      WebhookDelivery.Status s;
      try {
        s = WebhookDelivery.Status.valueOf(statusFilter.toLowerCase());
      } catch (IllegalArgumentException e) {
        throw OrdinisException.badRequest(
            "webhook_status_invalid",
            "Unknown delivery status: " + statusFilter
                + ". Allowed: pending, retrying, delivered, dlq.");
      }
      deliveries = deliveryRepository
          .findBySubscriptionIdAndStatusOrderByCreatedAtDesc(id, s, pr);
    } else {
      deliveries = deliveryRepository
          .findBySubscriptionIdOrderByCreatedAtDesc(id, pr);
    }
    return deliveries.map(WebhookDeliveryResponse::from);
  }

  @GetMapping("/deliveries/dlq")
  public Page<WebhookDeliveryResponse> dlq(
      @RequestParam(defaultValue = "0") int page,
      @RequestParam(defaultValue = "50") int size) {
    requireInternal();
    Page<WebhookDelivery> deliveries = deliveryRepository.findByStatus(
        WebhookDelivery.Status.dlq,
        PageRequest.of(page, Math.min(size, 200), Sort.by(Sort.Direction.DESC, "dlqAt")));
    return deliveries.map(WebhookDeliveryResponse::from);
  }

  /**
   * Time-bucketed histogram delivery'и подписки для admin UI chart'а.
   *
   * <p>Параметры:
   * <ul>
   *   <li>{@code bucketBy} — granularity. Default {@code "hour"}. Allowed:
   *       {@code "hour"} | {@code "day"}.</li>
   *   <li>{@code from} — нижняя граница (inclusive). Default = now - 24h
   *       для hour'a, now - 7d для day'a.</li>
   *   <li>{@code to} — верхняя граница (exclusive). Default = now.</li>
   * </ul>
   *
   * <p>Window cap: hour bucket'ом нельзя запросить больше 14 дней
   * (336 bucket'ов), day'ем — больше 365 (год). Это защита от
   * accident'ального overflow и keeps payload reasonable.
   *
   * <p>Response: {@link WebhookDeliveryStatsResponse} с continuous timeline
   * (пустые bucket'ы заполнены нулями — chart рендерит ровный X-axis без
   * gap'ов).
   */
  @GetMapping("/subscriptions/{id}/stats")
  public WebhookDeliveryStatsResponse stats(
      @PathVariable UUID id,
      @RequestParam(value = "bucketBy", defaultValue = "hour") String bucketBy,
      @RequestParam(value = "from", required = false) OffsetDateTime from,
      @RequestParam(value = "to", required = false) OffsetDateTime to) {
    requireInternal();

    // Validate bucketBy и резолвим default window.
    String unit = bucketBy.toLowerCase();
    if (!ALLOWED_BUCKETS.contains(unit)) {
      throw OrdinisException.badRequest(
          "webhook_stats_bucket_invalid",
          "Unknown bucketBy: " + bucketBy + ". Allowed: hour, day.");
    }

    OffsetDateTime now = OffsetDateTime.now();
    OffsetDateTime resolvedTo = to != null ? to : now;
    OffsetDateTime resolvedFrom =
        from != null
            ? from
            : resolvedTo.minus(unit.equals("hour") ? Duration.ofHours(24) : Duration.ofDays(7));

    if (!resolvedFrom.isBefore(resolvedTo)) {
      throw OrdinisException.badRequest(
          "webhook_stats_range_invalid",
          "from must be before to (from=" + resolvedFrom + ", to=" + resolvedTo + ").");
    }

    // Window cap — prevent runaway аллокации bucket'ов.
    long maxBuckets = unit.equals("hour") ? MAX_HOUR_BUCKETS : MAX_DAY_BUCKETS;
    ChronoUnit chronoUnit = unit.equals("hour") ? ChronoUnit.HOURS : ChronoUnit.DAYS;
    long requestedBuckets = chronoUnit.between(resolvedFrom, resolvedTo);
    if (requestedBuckets > maxBuckets) {
      throw OrdinisException.badRequest(
          "webhook_stats_window_too_large",
          "Запрошенное окно " + requestedBuckets + " " + unit
              + " bucket'ов превышает лимит " + maxBuckets + ". "
              + "Уменьшите диапазон или используйте более крупный bucketBy.");
    }

    // Native query → агрегируем в bucket map → fill empty bucket'ы.
    List<Object[]> rows = deliveryRepository.aggregateByBucket(
        id, unit, resolvedFrom, resolvedTo);

    // Map<bucketTs, Map<status, count>> — промежуточный shape для merge'а
    // status-row'ов в один Bucket dto. Native query возвращает по row на каждую
    // (ts, status) пару.
    //
    // Hibernate native query mapping для Postgres timestamp-without-tz column
    // возвращает {@link java.time.Instant} (default JDK time mapping). Bucket
    // key хотим OffsetDateTime в UTC — convert через atOffset(UTC). Это
    // совместимо с resolvedFrom.truncatedTo()/plus() ниже.
    Map<OffsetDateTime, Map<String, Long>> byTs = new HashMap<>();
    for (Object[] r : rows) {
      OffsetDateTime ts = toOffsetDateTime(r[0]);
      String status = (String) r[1];
      Long cnt = ((Number) r[2]).longValue();
      byTs.computeIfAbsent(ts, k -> new HashMap<>()).put(status, cnt);
    }

    // Continuous timeline — bucket'ы каждые `chronoUnit` от truncated(from) до to
    // (exclusive). Truncated чтобы первый bucket совпал с DB date_trunc.
    //
    // Bucket cursor + byTs keys both в UTC чтобы equals() работал. Postgres
    // date_trunc хранит результат в session TZ, но native query mapping
    // возвращает Instant (UTC). Cursor конвертим в UTC same instant для
    // консистентного lookup'a.
    OffsetDateTime cursor = truncateTo(resolvedFrom.withOffsetSameInstant(
        java.time.ZoneOffset.UTC), chronoUnit);
    OffsetDateTime endUtc = resolvedTo.withOffsetSameInstant(java.time.ZoneOffset.UTC);
    List<WebhookDeliveryStatsResponse.Bucket> buckets = new ArrayList<>();
    while (cursor.isBefore(endUtc)) {
      Map<String, Long> counts = byTs.getOrDefault(cursor, Map.of());
      long pending = counts.getOrDefault("pending", 0L);
      long retrying = counts.getOrDefault("retrying", 0L);
      long delivered = counts.getOrDefault("delivered", 0L);
      long dlq = counts.getOrDefault("dlq", 0L);
      long total = pending + retrying + delivered + dlq;
      buckets.add(new WebhookDeliveryStatsResponse.Bucket(
          cursor, pending, retrying, delivered, dlq, total));
      cursor = cursor.plus(1, chronoUnit);
    }

    return new WebhookDeliveryStatsResponse(unit, resolvedFrom, resolvedTo, buckets);
  }

  private static final Set<String> ALLOWED_BUCKETS = Set.of("hour", "day");
  private static final long MAX_HOUR_BUCKETS = 14L * 24; // 14 дней × 24h = 336
  private static final long MAX_DAY_BUCKETS = 365L;       // year

  /** Truncate timestamp до начала bucket'а — match'ит SQL date_trunc. */
  private static OffsetDateTime truncateTo(OffsetDateTime ts, ChronoUnit unit) {
    return ts.truncatedTo(unit);
  }

  /**
   * Convert native query timestamp value to {@code OffsetDateTime} в UTC.
   * Hibernate возвращает {@link java.time.Instant} для PG timestamp без TZ,
   * или OffsetDateTime для timestamp with TZ — handle оба case'а.
   */
  private static OffsetDateTime toOffsetDateTime(Object raw) {
    if (raw == null) return null;
    if (raw instanceof OffsetDateTime odt) return odt;
    if (raw instanceof java.time.Instant inst) {
      return inst.atOffset(java.time.ZoneOffset.UTC);
    }
    if (raw instanceof java.sql.Timestamp ts) {
      return ts.toInstant().atOffset(java.time.ZoneOffset.UTC);
    }
    throw new IllegalStateException(
        "Unexpected timestamp type from native query: " + raw.getClass().getName());
  }

  /**
   * Replay delivery из DLQ или retrying state — сбрасывает attemptCount=0,
   * dlqAt=null, status=retrying, nextAttemptAt=now. Dispatcher подхватит на
   * следующем sendTick.
   *
   * <p>Use case: оператор fixed receiver URL после deploy/cert renewal,
   * хочет replay'нуть failed delivery без ожидания TTL bypass'а.
   *
   * <p>RBAC: RESTRICTED — это destructive op (можно spam'ить receiver).
   */
  /**
   * Synchronous test ping — admin кликает кнопку в UI, receiver получает
   * synthetic event с теми же headers/HMAC что у production delivery.
   * Возвращает result inline для immediate feedback (latency, http status,
   * body preview / error).
   *
   * <p>Не порождает webhook_deliveries row — это test, не business event.
   * RBAC: RESTRICTED — потому что отправляет HTTP request на user-controlled URL.
   */
  @PostMapping("/subscriptions/{id}/test")
  public WebhookTestPingService.Result testSubscription(@PathVariable UUID id) {
    requireAdmin();
    WebhookSubscription sub = service.findById(id);
    return testPingService.testPing(sub);
  }

  @PostMapping("/deliveries/{id}/retry")
  public WebhookDeliveryResponse retryDelivery(@PathVariable Long id) {
    requireAdmin();
    WebhookDelivery delivery = deliveryRepository.findById(id)
        .orElseThrow(() -> OrdinisException.notFound(
            "webhook_delivery_not_found",
            "Webhook delivery " + id + " not found."));
    delivery.resetDlq();
    deliveryRepository.save(delivery);
    return WebhookDeliveryResponse.from(delivery);
  }

  // === Helpers ===

  /** RESTRICTED scope обязателен для mutating endpoints. */
  private void requireAdmin() {
    if (!scopeContext.canAccess(DataScope.RESTRICTED)) {
      throw OrdinisException.forbidden(
          "webhook_admin_required",
          "Webhook subscription management requires RESTRICTED scope (admin role).");
    }
  }

  /** INTERNAL+ scope для read endpoints. PUBLIC отказывается. */
  private void requireInternal() {
    if (!scopeContext.canAccess(DataScope.INTERNAL)) {
      throw OrdinisException.forbidden(
          "webhook_internal_required",
          "Webhook subscriptions accessible only with INTERNAL or RESTRICTED scope.");
    }
  }

  private static String currentUserId() {
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    return auth == null ? "anonymous" : auth.getName();
  }
}
