using System.Collections.Immutable; using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using StellaOps.Excititor.WebService.Services; namespace StellaOps.Excititor.WebService.Endpoints; internal static class IngestEndpoints { private const string AdminScope = "vex.admin"; public static void MapIngestEndpoints(IEndpointRouteBuilder app) { var group = app.MapGroup("/excititor"); group.MapPost("/init", HandleInitAsync); group.MapPost("/ingest/run", HandleRunAsync); group.MapPost("/ingest/resume", HandleResumeAsync); group.MapPost("/reconcile", HandleReconcileAsync); } internal static async Task HandleInitAsync( HttpContext httpContext, ExcititorInitRequest request, IVexIngestOrchestrator orchestrator, TimeProvider timeProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); if (scopeResult is not null) { return scopeResult; } var providerIds = NormalizeProviders(request.Providers); _ = timeProvider; var options = new IngestInitOptions(providerIds, request.Resume ?? false); var summary = await orchestrator.InitializeAsync(options, cancellationToken).ConfigureAwait(false); var message = $"Initialized {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed."; return TypedResults.Ok(new { message, runId = summary.RunId, startedAt = summary.StartedAt, completedAt = summary.CompletedAt, providers = summary.Providers.Select(static provider => new { providerId = provider.ProviderId, displayName = provider.DisplayName, status = provider.Status, durationMs = provider.Duration.TotalMilliseconds, error = provider.Error }) }); } internal static async Task HandleRunAsync( HttpContext httpContext, ExcititorIngestRunRequest request, IVexIngestOrchestrator orchestrator, TimeProvider timeProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); if (scopeResult is not null) { return scopeResult; } if (!TryParseDateTimeOffset(request.Since, out var since, out var sinceError)) { return TypedResults.BadRequest(new { message = sinceError }); } if (!TryParseTimeSpan(request.Window, out var window, out var windowError)) { return TypedResults.BadRequest(new { message = windowError }); } _ = timeProvider; var providerIds = NormalizeProviders(request.Providers); var options = new IngestRunOptions( providerIds, since, window, request.Force ?? false); var summary = await orchestrator.RunAsync(options, cancellationToken).ConfigureAwait(false); var message = $"Ingest run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed."; return TypedResults.Ok(new { message, runId = summary.RunId, startedAt = summary.StartedAt, completedAt = summary.CompletedAt, durationMs = summary.Duration.TotalMilliseconds, providers = summary.Providers.Select(static provider => new { providerId = provider.ProviderId, status = provider.Status, documents = provider.Documents, claims = provider.Claims, startedAt = provider.StartedAt, completedAt = provider.CompletedAt, durationMs = provider.Duration.TotalMilliseconds, lastDigest = provider.LastDigest, lastUpdated = provider.LastUpdated, checkpoint = provider.Checkpoint, error = provider.Error }) }); } internal static async Task HandleResumeAsync( HttpContext httpContext, ExcititorIngestResumeRequest request, IVexIngestOrchestrator orchestrator, TimeProvider timeProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); if (scopeResult is not null) { return scopeResult; } _ = timeProvider; var providerIds = NormalizeProviders(request.Providers); var options = new IngestResumeOptions(providerIds, request.Checkpoint); var summary = await orchestrator.ResumeAsync(options, cancellationToken).ConfigureAwait(false); var message = $"Resume run completed for {summary.ProviderCount} provider(s); {summary.SuccessCount} succeeded, {summary.FailureCount} failed."; return TypedResults.Ok(new { message, runId = summary.RunId, startedAt = summary.StartedAt, completedAt = summary.CompletedAt, durationMs = summary.Duration.TotalMilliseconds, providers = summary.Providers.Select(static provider => new { providerId = provider.ProviderId, status = provider.Status, documents = provider.Documents, claims = provider.Claims, startedAt = provider.StartedAt, completedAt = provider.CompletedAt, durationMs = provider.Duration.TotalMilliseconds, since = provider.Since, checkpoint = provider.Checkpoint, error = provider.Error }) }); } internal static async Task HandleReconcileAsync( HttpContext httpContext, ExcititorReconcileRequest request, IVexIngestOrchestrator orchestrator, TimeProvider timeProvider, CancellationToken cancellationToken) { var scopeResult = ScopeAuthorization.RequireScope(httpContext, AdminScope); if (scopeResult is not null) { return scopeResult; } if (!TryParseTimeSpan(request.MaxAge, out var maxAge, out var error)) { return TypedResults.BadRequest(new { message = error }); } _ = timeProvider; var providerIds = NormalizeProviders(request.Providers); var options = new ReconcileOptions(providerIds, maxAge); var summary = await orchestrator.ReconcileAsync(options, cancellationToken).ConfigureAwait(false); var message = $"Reconcile completed for {summary.ProviderCount} provider(s); {summary.ReconciledCount} reconciled, {summary.SkippedCount} skipped, {summary.FailureCount} failed."; return TypedResults.Ok(new { message, runId = summary.RunId, startedAt = summary.StartedAt, completedAt = summary.CompletedAt, durationMs = summary.Duration.TotalMilliseconds, providers = summary.Providers.Select(static provider => new { providerId = provider.ProviderId, status = provider.Status, action = provider.Action, lastUpdated = provider.LastUpdated, threshold = provider.Threshold, documents = provider.Documents, claims = provider.Claims, error = provider.Error }) }); } internal static ImmutableArray NormalizeProviders(IReadOnlyCollection? providers) { if (providers is null || providers.Count == 0) { return ImmutableArray.Empty; } var set = new SortedSet(StringComparer.OrdinalIgnoreCase); foreach (var provider in providers) { if (string.IsNullOrWhiteSpace(provider)) { continue; } set.Add(provider.Trim()); } return set.ToImmutableArray(); } internal static bool TryParseDateTimeOffset(string? value, out DateTimeOffset? result, out string? error) { result = null; error = null; if (string.IsNullOrWhiteSpace(value)) { return true; } if (DateTimeOffset.TryParse( value.Trim(), CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) { result = parsed; return true; } error = "Invalid 'since' value. Use ISO-8601 format (e.g. 2025-10-19T12:30:00Z)."; return false; } internal static bool TryParseTimeSpan(string? value, out TimeSpan? result, out string? error) { result = null; error = null; if (string.IsNullOrWhiteSpace(value)) { return true; } if (TimeSpan.TryParse(value.Trim(), CultureInfo.InvariantCulture, out var parsed) && parsed >= TimeSpan.Zero) { result = parsed; return true; } error = "Invalid duration value. Use TimeSpan format (e.g. 1.00:00:00)."; return false; } internal sealed record ExcititorInitRequest(IReadOnlyList? Providers, bool? Resume); internal sealed record ExcititorIngestRunRequest( IReadOnlyList? Providers, string? Since, string? Window, bool? Force); internal sealed record ExcititorIngestResumeRequest( IReadOnlyList? Providers, string? Checkpoint); internal sealed record ExcititorReconcileRequest( IReadOnlyList? Providers, string? MaxAge); }