using FrameworkBLL.EIPConversation.EIPHandlers.ChannelHandler; using FrameworkDAL.CustomCode.EIPConversation.EIPMessageTemplate; using FrameworkDAL.DTO.EIPConversation; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; namespace FrameworkBLL.EIPConversation.EIPHandlers.EIPEngine.ResponseEngine { public class EIPResponseEngine : IEIPResponseEngine { private const string DefaultLanguageCode = "en"; private readonly ILogger _logger; private readonly IEIPMessageTemplateDAL _messageTemplateDAL; private readonly IReadOnlyDictionary _handlerMap; // Thread-safe in-memory rate limiter (Key = "CHANNEL:RECIPIENT") private static readonly ConcurrentDictionary _rateLimiter = new ConcurrentDictionary(); private const int RATE_LIMIT_SECONDS = 2; public EIPResponseEngine( ILogger logger, IEIPMessageTemplateDAL messageTemplateDAL, IEnumerable handlers) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _messageTemplateDAL = messageTemplateDAL ?? throw new ArgumentNullException(nameof(messageTemplateDAL)); if (handlers == null || !handlers.Any()) throw new ArgumentNullException(nameof(handlers), "No channel handlers provided"); _handlerMap = handlers .ToDictionary(h => h.ChannelName, StringComparer.OrdinalIgnoreCase); _logger.LogInformation( "EIPResponseEngine initialized | HandlersCount={HandlerCount}", _handlerMap.Count); } private string GetChannelString(EIPChannelType channelType) => channelType switch { EIPChannelType.PostMan => "POSTMAN", EIPChannelType.WhatsApp => "WHATSAPP", EIPChannelType.Teams => "TEAMS", EIPChannelType.Telegram => "TELEGRAM", EIPChannelType.Slack => "SLACK", EIPChannelType.SMS => "SMS", EIPChannelType.AppChat => "APPCHAT", _ => "UNKNOWN" }; public async Task GenerateAsync( EIPResponseContext context, LoginDTO loginDTO, CancellationToken cancellationToken = default) { if (context == null) throw new ArgumentNullException(nameof(context)); if (loginDTO == null) throw new ArgumentNullException(nameof(loginDTO)); context.PhaseTrace ??= new List(); context.Metadata ??= new Dictionary(); var correlationId = Guid.NewGuid().ToString(); var recipient = context.Recipient?.Trim(); // Determine channel dynamically var channel = !string.IsNullOrWhiteSpace(context.Channel) ? context.Channel.Trim().ToUpperInvariant() : GetChannelString(context.ChannelType); AddPhase(context, "ResponseEngine", "START", $"GENERATE START | Channel={channel} | Recipient={recipient} | Tenant={context.TenantId} | FlowCode={context.FlowCode}"); try { // ========================= // VALIDATE RECIPIENT // ========================= if (string.IsNullOrWhiteSpace(recipient)) { AddPhase(context, "ResponseEngine", "FAILED", "Recipient validation failed: NULL or empty"); return LogAndReturnInvalidRecipient(correlationId, context); } AddPhase(context, "ResponseEngine", "SUCCESS", "Recipient validated successfully"); // ========================= // RATE LIMITING // ========================= var rateKey = $"{channel}:{recipient}".Replace(" ", "_").ToUpperInvariant(); var now = DateTime.UtcNow; if (!context.BypassRateLimit && _rateLimiter.TryGetValue(rateKey, out var lastSent) && (now - lastSent).TotalSeconds < RATE_LIMIT_SECONDS) { AddPhase(context, "ResponseEngine", "SKIPPED", $"Rate limit exceeded | LastSentUtc={lastSent}"); return new EIPChannelResponseDTO { IsSent = false, Status = "Rate limit exceeded" }; } _rateLimiter.AddOrUpdate(rateKey, now, (_, _) => now); // Evict entries outside the rate-limit window so the dictionary stays bounded var cutoff = now.AddSeconds(-(RATE_LIMIT_SECONDS + 5)); foreach (var staleKey in _rateLimiter.Keys.ToArray()) if (_rateLimiter.TryGetValue(staleKey, out var ts) && ts < cutoff) _rateLimiter.TryRemove(staleKey, out _); AddPhase(context, "ResponseEngine", "SUCCESS", "Rate limit check passed"); // ========================= // SELECT HANDLER // ========================= if (!_handlerMap.TryGetValue(channel, out var handler)) { AddPhase(context, "ResponseEngine", "FAILED", $"Handler selection failed | Channel={channel}"); _logger.LogError("[{CorrelationId}] No handler found for channel {Channel}", correlationId, channel); return new EIPChannelResponseDTO { IsSent = false, Status = $"No handler for channel: {channel}" }; } AddPhase(context, "ResponseEngine", "SUCCESS", $"Handler selected: {handler.GetType().Name}"); // ========================= // MESSAGE TEMPLATE LOOKUP // ========================= // Optional: when the step set a MessageKey, prefer the localized // MEIPMESSAGETEMPLATE row over the flow-JSON-authored message. Any lookup // failure or missing row falls back to context.Message untouched — this must // never be the reason a response fails to send. if (!string.IsNullOrWhiteSpace(context.MessageKey) && context.TenantId.HasValue) { try { var templateContent = await _messageTemplateDAL.GetTemplateContentAsync( context.TenantId.Value, context.MessageKey, string.IsNullOrWhiteSpace(context.LanguageCode) ? DefaultLanguageCode : context.LanguageCode, (byte)context.ChannelType, loginDTO, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(templateContent)) { context.Message = templateContent; AddPhase(context, "ResponseEngine", "INFO", $"Message resolved from MEIPMESSAGETEMPLATE | Key={context.MessageKey}"); } } catch (Exception templateEx) { _logger.LogWarning(templateEx, "Message template lookup failed — falling back to flow-authored message | MessageKey={MessageKey}", context.MessageKey); } } // ========================= // VALIDATE MESSAGE / PAYLOAD // ========================= if (string.IsNullOrWhiteSpace(context.Message) && string.IsNullOrWhiteSpace(context.MediaUrl) && (context.Buttons == null || !context.Buttons.Any())) { AddPhase(context, "ResponseEngine", "WARNING", "Message, Media, and Buttons are empty"); _logger.LogWarning("[{CorrelationId}] Empty payload | Recipient={Recipient}", correlationId, recipient); } if (context.Buttons?.Any(b => string.IsNullOrWhiteSpace(b.ButtonText)) == true) { AddPhase(context, "ResponseEngine", "WARNING", "One or more buttons have empty text"); _logger.LogWarning("[{CorrelationId}] Buttons have empty text | Recipient={Recipient}", correlationId, recipient); } if (string.IsNullOrWhiteSpace(context.Message)) { context.Message = "Default response message."; AddPhase(context, "ResponseEngine", "INFO", "Default message applied"); } // ========================= // SEND MESSAGE // ========================= var response = await handler.SendAsync(context, loginDTO, cancellationToken) .ConfigureAwait(false); response ??= new EIPChannelResponseDTO { IsSent = false, Status = "Handler returned null response" }; AddPhase(context, "ResponseEngine", "SUCCESS", $"Message sent | IsSent={response.IsSent} | Status={response.Status}"); // ========================= // UPDATE METADATA // ========================= context.Metadata["LastSentUtc"] = now; context.Metadata["Recipient"] = recipient; context.Metadata["Channel"] = channel; context.Metadata["ResponseStatus"] = response.Status; context.Metadata["FlowCode"] = context.FlowCode ?? "UNKNOWN"; // ========================= // LOG RESPONSE // ========================= _logger.LogInformation( "[{CorrelationId}] Response completed | Channel={Channel} | Recipient={Recipient} | IsSent={IsSent} | Status={Status} | ExternalMessageId={ExternalMessageId}", correlationId, channel, recipient, response.IsSent, response.Status, response.ExternalMessageId); _logger.LogDebug("[{CorrelationId}] Full Response Object | {@Response}", correlationId, response); return response; } catch (OperationCanceledException) { AddPhase(context, "ResponseEngine", "CANCELLED", "Operation cancelled by token"); _logger.LogWarning("[{CorrelationId}] GenerateAsync cancelled | Channel={Channel} | Recipient={Recipient}", correlationId, channel, recipient); throw; } catch (Exception ex) { AddPhase(context, "ResponseEngine", "FAILED", $"Exception occurred: {ex.Message}"); _logger.LogError(ex, "[{CorrelationId}] GenerateAsync failed | Channel={Channel} | Recipient={Recipient}", correlationId, channel, recipient); return new EIPChannelResponseDTO { IsSent = false, Status = ex.Message, ErrorMessage = ex.ToString() }; } finally { AddPhase(context, "ResponseEngine", "END", "GENERATE END"); _logger.LogInformation("[{CorrelationId}] GENERATE END | Channel={Channel} | Recipient={Recipient}", correlationId, channel, recipient); } } private void AddPhase(EIPResponseContext context, string phaseName, string status, string description) { context.PhaseTrace.Add(new EIPExecutionPhaseDTO { PhaseName = phaseName, Status = status, Description = description, Timestamp = DateTime.UtcNow }); } private EIPChannelResponseDTO LogAndReturnInvalidRecipient(string correlationId, EIPResponseContext context) { AddPhase(context, "ResponseEngine", "FAILED", "Invalid recipient (NULL or empty)"); _logger.LogError("[{CorrelationId}] Invalid recipient (NULL or empty)", correlationId); return new EIPChannelResponseDTO { IsSent = false, Status = "Invalid recipient" }; } } }