using System; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FrameworkDAL.CustomCode.GOP; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.GOP; using Microsoft.Extensions.Logging; namespace FrameworkBLL.GOP { public class GopDocumentIntakeBLL : IGopDocumentIntakeBLL { private const string HttpClientName = "eaiadmin-internal"; private const string DocumentIntakeInitiative = "DOCUMENT_INTAKE"; private const string IdentifyCapability = "IDENTIFY_DOCUMENT"; private const string ParseValidateCapability = "PARSE_VALIDATE_DOCUMENT"; private readonly IHttpClientFactory _httpClientFactory; private readonly IDocumentTypeDAL _documentTypeDAL; private readonly IGopQueueBLL _gopQueueBLL; private readonly ILogger _logger; public GopDocumentIntakeBLL( IHttpClientFactory httpClientFactory, IDocumentTypeDAL documentTypeDAL, IGopQueueBLL gopQueueBLL, ILogger logger) { _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); _documentTypeDAL = documentTypeDAL ?? throw new ArgumentNullException(nameof(documentTypeDAL)); _gopQueueBLL = gopQueueBLL ?? throw new ArgumentNullException(nameof(gopQueueBLL)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task SubmitDocumentAsync( Stream fileStream, string fileName, string contentType, LoginDTO login, CancellationToken ct = default) { if (fileStream is null || fileStream.Length == 0) throw new ArgumentException("File is required."); using var ms = new MemoryStream(); await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false); var fileBytes = ms.ToArray(); try { // 1. Identify — classify the document type. var identifyResultJson = await CallExecuteCapabilityAsync( DocumentIntakeInitiative, IdentifyCapability, fileBytes, fileName, contentType, login, ct) .ConfigureAwait(false); var classificationLabel = ExtractClassificationLabel(identifyResultJson); if (string.IsNullOrWhiteSpace(classificationLabel)) { _logger.LogWarning( "GopDocumentIntakeBLL | No classification label found in IDENTIFY_DOCUMENT response | Raw={Raw}", identifyResultJson); return new GopDocumentIntakeResultDTO { Message = "Could not determine the document's type — no classification label in the AI response." }; } // 2. Resolve DocumentType — match the classification against MGOPDOCUMENTTYPE. var documentTypes = await _documentTypeDAL.GetList(login, ct).ConfigureAwait(false); var documentType = documentTypes.FirstOrDefault(d => string.Equals(d.AiClassificationLabel, classificationLabel, StringComparison.OrdinalIgnoreCase)); if (documentType is null) { _logger.LogWarning( "GopDocumentIntakeBLL | No MGOPDOCUMENTTYPE matches classification '{Label}' | Tenant={TenantId}", classificationLabel, login.ClientId); return new GopDocumentIntakeResultDTO { Message = $"Document classified as '{classificationLabel}', but no matching DocumentType is configured." }; } // 3. Parse/validate — convert the file into structured JSON. var parseResultJson = await CallExecuteCapabilityAsync( DocumentIntakeInitiative, ParseValidateCapability, fileBytes, fileName, contentType, login, ct) .ConfigureAwait(false); // 4. Submit — the existing GOP source-binding resolution, unchanged. var submitResult = await _gopQueueBLL.SubmitExecution( new GB5Shared.DTO.GOP.GopSubmitRequestDTO { SourceCode = documentType.EntityCode, PayloadJson = parseResultJson }, login).ConfigureAwait(false); return new GopDocumentIntakeResultDTO { ExecutionId = submitResult.ExecutionId, DocumentTypeName = documentType.DocumentTypeName, Message = submitResult.Message }; } catch (Exception ex) { _logger.LogError(ex, "GopDocumentIntakeBLL | SubmitDocument | Error | Tenant={TenantId} File={FileName}", login.ClientId, fileName); throw; } } public async Task SubmitDocumentForPrefillAsync( Stream fileStream, string fileName, string contentType, string sourceCode, LoginDTO login, CancellationToken ct = default) { if (fileStream is null || fileStream.Length == 0) throw new ArgumentException("File is required."); if (string.IsNullOrWhiteSpace(sourceCode)) throw new ArgumentException("SourceCode is required."); using var ms = new MemoryStream(); await fileStream.CopyToAsync(ms, ct).ConfigureAwait(false); var fileBytes = ms.ToArray(); try { // Caller already knows the document type — no IDENTIFY_DOCUMENT/DocumentType // lookup, straight to parse/convert. var parseResultJson = await CallExecuteCapabilityAsync( DocumentIntakeInitiative, ParseValidateCapability, fileBytes, fileName, contentType, login, ct) .ConfigureAwait(false); var submitResult = await _gopQueueBLL.SubmitExecution( new GB5Shared.DTO.GOP.GopSubmitRequestDTO { SourceCode = sourceCode, PayloadJson = parseResultJson }, login).ConfigureAwait(false); return new GopDocumentIntakeResultDTO { ExecutionId = submitResult.ExecutionId, Message = submitResult.Message }; } catch (Exception ex) { _logger.LogError(ex, "GopDocumentIntakeBLL | SubmitDocumentForPrefill | Error | Tenant={TenantId} SourceCode={SourceCode} File={FileName}", login.ClientId, sourceCode, fileName); throw; } } // Loopback call to EAIAdmin (hosted by PlatformHost, a separate process from this // Framework host) — same "Login header + named HttpClient" convention as // EIPRestActionHandler's "eip-internal" client. Same topology caveat as // "entitlement-internal": only reachable where a real gateway routes /EAIAdmin/* to // wherever PlatformHost actually lives. private async Task CallExecuteCapabilityAsync( string initiativeCode, string capabilityCode, byte[] fileBytes, string fileName, string contentType, LoginDTO login, CancellationToken ct) { var client = _httpClientFactory.CreateClient(HttpClientName); var url = $"EAIAdmin/ExecuteCapability?InitiativeCode={Uri.EscapeDataString(initiativeCode)}" + $"&CapabilityCode={Uri.EscapeDataString(capabilityCode)}"; using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.TryAddWithoutValidation("Login", JsonSerializer.Serialize(login)); using var form = new MultipartFormDataContent(); var fileContent = new ByteArrayContent(fileBytes); fileContent.Headers.ContentType = new MediaTypeHeaderValue( string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType); form.Add(fileContent, "file", fileName); request.Content = form; using var response = await client.SendAsync(request, ct).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new InvalidOperationException( $"EAIAdmin/ExecuteCapability ({capabilityCode}) returned HTTP {(int)response.StatusCode}: {body}"); using var doc = JsonDocument.Parse(body); return doc.RootElement.TryGetProperty("Body", out var responseBody) && responseBody.TryGetProperty("ResultJson", out var resultJson) && resultJson.ValueKind == JsonValueKind.String ? resultJson.GetString() ?? "{}" : "{}"; } // gbEAI's document_identifier ultimately proxies to an external classify-document service // whose exact response shape isn't visible from this repo — try the common field-name // candidates in order; MGOPDOCUMENTTYPE.AiClassificationLabel is admin-editable, so once // the real shape is confirmed (see the raw JSON logged on a miss above) this can be // corrected without redeploying this code too, if the real field name differs. private static string? ExtractClassificationLabel(string resultJson) { try { using var doc = JsonDocument.Parse(resultJson); if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; foreach (var fieldName in new[] { "document_type", "type", "classification", "label", "category" }) { if (doc.RootElement.TryGetProperty(fieldName, out var el) && el.ValueKind == JsonValueKind.String) return el.GetString(); } return null; } catch (JsonException) { return null; } } } }