using System; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GB5Shared.DateConverter; using GB5Shared.DTO.Framework.Login; using GB5Shared.DTO.Promotion; using GB5Shared.Promotion; using Microsoft.Extensions.Configuration; using Newtonsoft.Json; namespace FrameworkBLL.Promotion.Adapters { // ============================================================ // RemoteHttpPromotionAdapter — one instance per cross-host promotable entity type // (e.g. keyed "TRAININGPROGRAMME"), calling that entity's owning module's own // PromotionBridge endpoints over HTTP instead of an in-process BLL call. // // GOP's execution pipeline runs only inside FrameworkSL's own standalone process; entities // owned by any other module (TMS -> HRFinanceHost, CMS -> EngagementHost, etc.) live in a // genuinely separate process with no shared DI container. See // GB5Shared/DTO/Promotion/PromotionBridgeDTOs.cs for the full reasoning. This class reuses the // exact same cross-host HTTP pattern already proven by JobEngineScheduleClient // (AutomationBLL -> JobEngine host) and the "entitlement-internal" named HttpClient in // FrameworkSL/Program.cs: a plain internal HTTP call carrying a "Login" header with the // serialized LoginDTO — no separate machine credential, same trust model as any other GB5 // endpoint reached over the internal network. // // Deliberately NOT non-fatal-by-design like JobEngineScheduleClient's own degrade-to-null // convention — that pattern fits an optional sync side-effect (a schedule row still gets saved // locally either way), but a Promotion Import step with no working destination call has nothing // useful to fall back to, so failures here propagate as real exceptions, same as every // in-process IPromotableEntity adapter's own failure behaviour. // ============================================================ public class RemoteHttpPromotionAdapter : IPromotableEntity { public string EntityTypeCode { get; } // Same converters every GB5 endpoint's own serializer uses (BaseEndpoint's // c.Serializer.Options.AddGB5Converters() call) — without these, a legacy /Date(N)/-style // DateTime on the wire (e.g. PromotionBundleDTO.SubmittedOn) fails plain System.Text.Json // deserialization with "could not be converted to System.DateTime". private static readonly JsonSerializerOptions _JsonOptions = GB5JsonOptions.CreateDefaultOptions(); private readonly IHttpClientFactory _HttpClientFactory; private readonly IConfiguration _Configuration; private readonly string _ModuleConfigKey; public RemoteHttpPromotionAdapter( IHttpClientFactory httpClientFactory, IConfiguration configuration, string entityTypeCode, string moduleConfigKey) { _HttpClientFactory = httpClientFactory; _Configuration = configuration; EntityTypeCode = entityTypeCode; _ModuleConfigKey = moduleConfigKey; } public async Task ExportForPromotionAsync( int originEntityId, LoginDTO login, CancellationToken ct) { var body = new PromotionBridgeExportRequestDTO { EntityTypeCode = EntityTypeCode, OriginEntityId = originEntityId }; var responseBody = await PostAsync("/PromotionBridge/ExportForPromotion", body, login, ct).ConfigureAwait(false); return responseBody.Deserialize(_JsonOptions) ?? throw new InvalidOperationException( $"Remote Export for '{EntityTypeCode}' on module '{_ModuleConfigKey}' returned an empty response."); } public async Task ValidateForPromotionAsync( PromotionBundleDTO bundle, CancellationToken ct) { var responseBody = await PostAsync("/PromotionBridge/ValidateForPromotion", bundle, LoginPlaceholder(bundle), ct) .ConfigureAwait(false); return responseBody.Deserialize(_JsonOptions) ?? throw new InvalidOperationException( $"Remote Validate for '{EntityTypeCode}' on module '{_ModuleConfigKey}' returned an empty response."); } public async Task ImportPromotedAsync( PromotionBundleDTO bundle, byte destinationSourceType, int? existingDestinationId, LoginDTO login, CancellationToken ct) { var body = new PromotionBridgeImportRequestDTO { Bundle = bundle, DestinationSourceType = destinationSourceType, ExistingDestinationId = existingDestinationId }; var responseBody = await PostAsync("/PromotionBridge/ImportPromoted", body, login, ct).ConfigureAwait(false); return responseBody.Deserialize(_JsonOptions); } // ValidateForPromotionAsync's signature carries no LoginDTO (matching IPromotableEntity — // validation is meant to be login-independent, re-run at both export and ingest time), but // the bridge endpoint still needs a Login header for BaseEndpoint's own request pipeline. // A minimal framework-level identity is enough here since the remote endpoint only uses it // for tracing/logging, never for tenant-scoped data access during validation. private static LoginDTO LoginPlaceholder(PromotionBundleDTO bundle) => new() { ClientId = -1 }; private async Task PostAsync(string path, object body, LoginDTO login, CancellationToken ct) { var baseUrl = _Configuration[$"Promotion:RemoteModules:{_ModuleConfigKey}:BaseUrl"] ?? throw new InvalidOperationException( $"Promotion:RemoteModules:{_ModuleConfigKey}:BaseUrl is not configured."); var http = _HttpClientFactory.CreateClient("promotion-remote"); using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl.TrimEnd('/')}{path}") { Content = JsonContent.Create(body, options: _JsonOptions) }; request.Headers.Add("Login", JsonConvert.SerializeObject(login)); using var response = await http.SendAsync(request, ct).ConfigureAwait(false); response.EnsureSuccessStatusCode(); var envelope = await response.Content.ReadFromJsonAsync(cancellationToken: ct).ConfigureAwait(false); // FrameworkEnumDTO.ResponseStatus.Ok = 200 — the only non-error value CreateSuccessResponse sets. if (!envelope.TryGetProperty("Status", out var statusProp) || statusProp.GetInt32() != 200) { var errorMessage = envelope.TryGetProperty("ErrorBody", out var errProp) ? errProp.GetString() : "Remote Promotion bridge call failed."; throw new InvalidOperationException( $"Remote Promotion bridge call to '{path}' on module '{_ModuleConfigKey}' failed: {errorMessage}"); } return envelope.GetProperty("Body"); } } }