using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using StellaOps.Auth.Abstractions; using StellaOps.Auth.Client; using StellaOps.Cli.Configuration; using StellaOps.Cli.Extensions; using StellaOps.Cli.Services.Models; namespace StellaOps.Cli.Services; /// /// Client for Notify API operations. /// Per CLI-PARITY-41-002. /// internal sealed class NotifyClient : INotifyClient { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web); private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30); private readonly HttpClient httpClient; private readonly StellaOpsCliOptions options; private readonly ILogger logger; private readonly IStellaOpsTokenClient? tokenClient; private readonly object tokenSync = new(); private string? cachedAccessToken; private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue; public NotifyClient( HttpClient httpClient, StellaOpsCliOptions options, ILogger logger, IStellaOpsTokenClient? tokenClient = null) { this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); this.options = options ?? throw new ArgumentNullException(nameof(options)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.tokenClient = tokenClient; if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null) { if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri)) { httpClient.BaseAddress = baseUri; } } } public async Task ListChannelsAsync( NotifyChannelListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var uri = BuildChannelListUri(request); using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to list notify channels (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyChannelListResponse(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyChannelListResponse(); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while listing notify channels"); return new NotifyChannelListResponse(); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while listing notify channels"); return new NotifyChannelListResponse(); } } public async Task GetChannelAsync( string channelId, string? tenant, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(channelId); try { EnsureConfigured(); var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}"; if (!string.IsNullOrWhiteSpace(tenant)) { uri += $"?tenant={Uri.EscapeDataString(tenant)}"; } using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get notify channel (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while getting notify channel"); return null; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while getting notify channel"); return null; } } public async Task TestChannelAsync( NotifyChannelTestRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test") { Content = content }; await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to test notify channel (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}" }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while testing notify channel"); return new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = $"Connection error: {ex.Message}" }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while testing notify channel"); return new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Request timed out" }; } } public async Task ListRulesAsync( NotifyRuleListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } if (request.Enabled.HasValue) { queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}"); } if (!string.IsNullOrWhiteSpace(request.EventType)) { queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}"); } if (!string.IsNullOrWhiteSpace(request.ChannelId)) { queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}"); } if (request.Limit.HasValue) { queryParams.Add($"limit={request.Limit.Value}"); } if (request.Offset.HasValue) { queryParams.Add($"offset={request.Offset.Value}"); } var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; var uri = $"/api/v1/notify/rules{query}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to list notify rules (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyRuleListResponse(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyRuleListResponse(); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while listing notify rules"); return new NotifyRuleListResponse(); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while listing notify rules"); return new NotifyRuleListResponse(); } } public async Task ListDeliveriesAsync( NotifyDeliveryListRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } if (!string.IsNullOrWhiteSpace(request.ChannelId)) { queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}"); } if (!string.IsNullOrWhiteSpace(request.Status)) { queryParams.Add($"status={Uri.EscapeDataString(request.Status)}"); } if (!string.IsNullOrWhiteSpace(request.EventType)) { queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}"); } if (request.Since.HasValue) { queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O", CultureInfo.InvariantCulture))}"); } if (request.Until.HasValue) { queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O", CultureInfo.InvariantCulture))}"); } if (request.Limit.HasValue) { queryParams.Add($"limit={request.Limit.Value}"); } if (!string.IsNullOrWhiteSpace(request.Cursor)) { queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}"); } var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; var uri = $"/api/v1/notify/deliveries{query}"; using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to list notify deliveries (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyDeliveryListResponse(); } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyDeliveryListResponse(); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while listing notify deliveries"); return new NotifyDeliveryListResponse(); } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while listing notify deliveries"); return new NotifyDeliveryListResponse(); } } public async Task GetDeliveryAsync( string deliveryId, string? tenant, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId); try { EnsureConfigured(); var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}"; if (!string.IsNullOrWhiteSpace(tenant)) { uri += $"?tenant={Uri.EscapeDataString(tenant)}"; } using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri); await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to get notify delivery (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return null; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while getting notify delivery"); return null; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while getting notify delivery"); return null; } } public async Task RetryDeliveryAsync( NotifyRetryRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry") { Content = content }; // Add idempotency key header if present if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) { httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey); } await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to retry notify delivery (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while retrying notify delivery"); return new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while retrying notify delivery"); return new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Request timed out"] }; } } public async Task SendAsync( NotifySendRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send") { Content = content }; // Add idempotency key header if present if (!string.IsNullOrWhiteSpace(request.IdempotencyKey)) { httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey); } await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to send notification (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifySendResult { Success = false, Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"] }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while sending notification"); return new NotifySendResult { Success = false, Errors = [$"Connection error: {ex.Message}"] }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while sending notification"); return new NotifySendResult { Success = false, Errors = ["Request timed out"] }; } } public async Task SimulateAsync( NotifySimulationRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var json = JsonSerializer.Serialize(request, SerializerOptions); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/simulate") { Content = content }; if (!string.IsNullOrWhiteSpace(request.TenantId)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId); } await AuthorizeRequestAsync(httpRequest, "notify.simulate", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to simulate notify rules (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while simulating notify rules"); return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while simulating notify rules"); return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 }; } } public async Task AckAsync( NotifyAckRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); try { EnsureConfigured(); var hasToken = !string.IsNullOrWhiteSpace(request.Token); using var httpRequest = hasToken ? new HttpRequestMessage(HttpMethod.Get, $"/api/v2/ack?token={Uri.EscapeDataString(request.Token!)}") : new HttpRequestMessage(HttpMethod.Post, "/api/v2/ack") { Content = new StringContent(JsonSerializer.Serialize(new AckApiRequestBody { TenantId = request.TenantId, IncidentId = request.IncidentId, AcknowledgedBy = request.AcknowledgedBy, Comment = request.Comment }, SerializerOptions), Encoding.UTF8, "application/json") }; if (!string.IsNullOrWhiteSpace(request.TenantId)) { httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId); } await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false); using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); logger.LogError( "Failed to acknowledge notification (status {StatusCode}). Response: {Payload}", (int)response.StatusCode, string.IsNullOrWhiteSpace(payload) ? "" : payload); return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = payload }; } await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); var result = await JsonSerializer .DeserializeAsync(stream, SerializerOptions, cancellationToken) .ConfigureAwait(false); return result ?? new NotifyAckResult { Success = true, IncidentId = request.IncidentId }; } catch (HttpRequestException ex) { logger.LogError(ex, "HTTP error while acknowledging notification"); return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = ex.Message }; } catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) { logger.LogError(ex, "Request timed out while acknowledging notification"); return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = "Request timed out" }; } } private sealed record AckApiRequestBody { public string? TenantId { get; init; } public string? IncidentId { get; init; } public string? AcknowledgedBy { get; init; } public string? Comment { get; init; } } private static string BuildChannelListUri(NotifyChannelListRequest request) { var queryParams = new List(); if (!string.IsNullOrWhiteSpace(request.Tenant)) { queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}"); } if (!string.IsNullOrWhiteSpace(request.Type)) { queryParams.Add($"type={Uri.EscapeDataString(request.Type)}"); } if (request.Enabled.HasValue) { queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}"); } if (request.Limit.HasValue) { queryParams.Add($"limit={request.Limit.Value}"); } if (request.Offset.HasValue) { queryParams.Add($"offset={request.Offset.Value}"); } if (!string.IsNullOrWhiteSpace(request.Cursor)) { queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}"); } var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; return $"/api/v1/notify/channels{query}"; } private void EnsureConfigured() { if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null) { throw new InvalidOperationException( "Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url."); } } private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken) { var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); } } private async Task GetAccessTokenAsync(string scope, CancellationToken cancellationToken) { if (tokenClient is null) { return null; } lock (tokenSync) { if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew) { return cachedAccessToken; } } try { var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false); lock (tokenSync) { cachedAccessToken = result.AccessToken; cachedAccessTokenExpiresAt = result.ExpiresAt; } return result.AccessToken; } catch (Exception ex) { logger.LogWarning(ex, "Token acquisition failed"); return null; } } }