using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GB5Shared.Auth.Jwt; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; namespace GB5Shared.ExternalAI { /// /// Default IAIEngineSyncClient implementation — reuses the exact JWT-mint + HttpClient call /// pattern already proven live in FrameworkBLL.GOP.Worker.NodeExecutors.AIExtractNodeExecutor. /// Constructor is deliberately DI-plain (no per-call config bound at construction) so this /// class can be registered once, centrally, and shared by every module that needs to talk to /// the external Enterprise AI engine — each caller supplies its own AIEngineOptions per call. /// public class AIEngineHttpClient : IAIEngineSyncClient { private const string HttpClientName = "ai-enterprise"; private readonly IHttpClientFactory _httpClientFactory; private readonly IJwtAccessTokenIssuer _jwtIssuer; private readonly ILogger _logger; public AIEngineHttpClient( IHttpClientFactory httpClientFactory, IJwtAccessTokenIssuer jwtIssuer, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _jwtIssuer = jwtIssuer ?? throw new ArgumentNullException(nameof(jwtIssuer)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SendJsonAsync( AIEngineOptions options, HttpMethod method, string relativePath, object? jsonBody, LoginDTO login, CancellationToken ct = default) { try { var accessToken = await IssueTokenAsync(options, login, ct).ConfigureAwait(false); var requestUrl = $"{options.EngineBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}"; using var request = new HttpRequestMessage(method, requestUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); if (jsonBody is not null) { var json = JsonSerializer.Serialize(jsonBody); request.Content = new StringContent(json, Encoding.UTF8, "application/json"); } return await SendAsync(options, request, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "AIEngineHttpClient | SendJsonAsync | Failed | Path={Path}", relativePath); return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message }; } } public async Task SendMultipartAsync( AIEngineOptions options, string relativePath, IReadOnlyDictionary formFields, (string FileName, byte[] Bytes)? file, LoginDTO login, CancellationToken ct = default) { try { var accessToken = await IssueTokenAsync(options, login, ct).ConfigureAwait(false); var requestUrl = $"{options.EngineBaseUrl.TrimEnd('/')}/{relativePath.TrimStart('/')}"; using var request = new HttpRequestMessage(HttpMethod.Post, requestUrl); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var form = new MultipartFormDataContent(); foreach (var kvp in formFields) form.Add(new StringContent(kvp.Value ?? string.Empty), kvp.Key); if (file is { } f) { var fileContent = new ByteArrayContent(f.Bytes); fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); form.Add(fileContent, "file", f.FileName); } request.Content = form; return await SendAsync(options, request, ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "AIEngineHttpClient | SendMultipartAsync | Failed | Path={Path}", relativePath); return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message }; } } private async Task IssueTokenAsync(AIEngineOptions options, LoginDTO login, CancellationToken ct) { var claims = new AIEngineAccessTokenClaims { TenantId = login.ClientId, UserId = login.UserId }; var issued = await _jwtIssuer.IssueAsync( claims, options.SigningKeyVaultPath, options.Issuer, options.Audience, options.AccessTokenMinutes, ct).ConfigureAwait(false); return issued.AccessToken; } private async Task SendAsync( AIEngineOptions options, HttpRequestMessage request, CancellationToken ct) { var client = _httpClientFactory.CreateClient(HttpClientName); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(TimeSpan.FromSeconds(options.TimeoutSeconds > 0 ? options.TimeoutSeconds : 120)); try { using var response = await client.SendAsync(request, timeoutCts.Token).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); return new AIEngineResponse { IsSuccess = response.IsSuccessStatusCode, StatusCode = (int)response.StatusCode, Body = body, ErrorMessage = response.IsSuccessStatusCode ? null : $"HTTP {(int)response.StatusCode}: {body}" }; } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // Our own timeout fired, not the caller's token. return new AIEngineResponse { IsSuccess = false, ErrorMessage = "Request to AI-Enterprise-v1.0 timed out." }; } catch (HttpRequestException ex) { _logger.LogWarning(ex, "AIEngineHttpClient | SendAsync | Network error | Url={Url}", request.RequestUri); return new AIEngineResponse { IsSuccess = false, ErrorMessage = ex.Message }; } } } }