using FluentValidation; using GB5Shared.Connection; using GB5Shared.DTO.Framework.Logging; using GB5Shared.DTO.Framework.Login; using GB5Shared.QueryExecutor; using GB5Shared.Telemetry.Database; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace GB5Shared.Validation { /// /// Centralized validation library for DTOs and entities. /// Provides asynchronous, exception-safe, developer-friendly validation utilities. /// public class Validation : IValidation { private readonly ILogger _Logger; private readonly IApplicationConnection _ApplicationConnection; public Validation(ILogger logger, IApplicationConnection applicationConnection) { _Logger = logger ?? throw new ArgumentNullException(nameof(logger)); _ApplicationConnection = applicationConnection ?? throw new ArgumentNullException(nameof(applicationConnection)); // Previously built its own IConfiguration and used "Gb5SystemDTO:Gb5System" as a raw, // unencrypted ADO.NET connection string — inconsistent with every real deployed // environment, where that same config key's Password= segment is AES-256-GCM // encrypted (confirmed live: FrameworkSL/appsettings.json's own committed value decrypts // correctly via ApplicationConnection.Decrypt, but fails outright if used as-is). Now // reuses IApplicationConnection.Gb5SystemConnectionString() — the single already-correct // decrypt path — instead of duplicating (and getting wrong) that logic here. } public async Task NotNull(object value, string? fieldName = null) { await Task.Run(() => { try { if (value == null) throw new ArgumentNullException(GetFieldName(fieldName, value), $"{GetFieldName(fieldName, value)} cannot be null."); } catch (Exception ex) { throw new Exception($"Validation failed: {ex.Message}", ex); } }); } public async Task NotEmpty(string value, string? fieldName = null) { await Task.Run(() => { try { string field = GetFieldName(fieldName, value); if (string.IsNullOrWhiteSpace(value)) throw new ValidationException($"{field} cannot be empty or whitespace."); } catch (ValidationException) { // Directly rethrow validation errors throw; } catch (Exception ex) { throw new Exception($"Validation failed: {ex.Message}", ex); } }); } public async Task NotNullOrEmpty(string? value, string? fieldName = null) { await Task.Run(() => { try { string field = GetFieldName(fieldName, value); if (value == null) throw new ArgumentNullException(field, $"{field} cannot be null."); if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException($"{field} cannot be empty or whitespace."); } catch (Exception ex) { throw new Exception($"Validation failed: {ex.Message}", ex); } }); } public async Task MaxLength(string tableName, string columnName, string? value) { if (value == null) return; int? maxLength = await GetColumnMaxLengthAsync(tableName, columnName); if (maxLength == null) throw new Exception($"Could not determine max length for {tableName}.{columnName}"); if (value.Length > maxLength) throw new Exception($"{columnName} exceeds maximum length of {maxLength} characters."); } private async Task GetColumnMaxLengthAsync(string tableName, string columnName) { const string sql = @" SELECT CHARACTER_MAXIMUM_LENGTH FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @TableName AND COLUMN_NAME = @ColumnName"; var queryParams = new Dictionary { ["TableName"] = tableName, ["ColumnName"] = columnName }; using var dbActivity = DatabaseActivityHelper.StartDbActivity(sql, parameters: queryParams); try { var connectionString = await _ApplicationConnection.Gb5SystemConnectionString(); await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@TableName", tableName); cmd.Parameters.AddWithValue("@ColumnName", columnName); object result = await cmd.ExecuteScalarAsync(); dbActivity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); return result == DBNull.Value ? null : Convert.ToInt32(result); } catch (Exception dbEx) { DatabaseActivityHelper.RecordDbError(dbActivity, dbEx); throw; } } public async Task Range(T min, T max, T value, string? fieldName = null) where T : IComparable { await Task.Run(() => { if (min.CompareTo(value) > 0 || max.CompareTo(value) < 0) { throw new Exception($"{fieldName ?? "Value"} is out of range ({min} to {max})."); } }); } public async Task HandleException(Exception ex, string contextMessage = "") { if (ex == null) throw new ArgumentNullException(nameof(ex)); try { string finalMessage; switch (ex) { case SqlException sqlEx: finalMessage = ParseSqlError(sqlEx); _Logger.LogError(sqlEx, "[SQL ERROR] {Context}: {Error}", contextMessage, finalMessage); break; case AggregateException aggEx when aggEx.InnerException is SqlException innerSql: finalMessage = ParseSqlError(innerSql); _Logger.LogError(innerSql, "[SQL ERROR] {Context}: {Error}", contextMessage, finalMessage); break; case ArgumentNullException argEx: finalMessage = $"Missing parameter: {argEx.ParamName ?? "Unknown"}. {argEx.Message}"; _Logger.LogWarning(argEx, "[ARGUMENT ERROR] {Context}: {Error}", contextMessage, finalMessage); break; case ArgumentException argEx: finalMessage = $"Invalid argument: {argEx.Message}"; _Logger.LogWarning(argEx, "[ARGUMENT ERROR] {Context}: {Error}", contextMessage, finalMessage); break; case InvalidOperationException invalidOp: finalMessage = $"Operation not valid in current state: {invalidOp.Message}"; _Logger.LogError(invalidOp, "[INVALID OPERATION] {Context}: {Error}", contextMessage, finalMessage); break; case TimeoutException timeoutEx: finalMessage = $"The request timed out: {timeoutEx.Message}"; _Logger.LogError(timeoutEx, "[TIMEOUT] {Context}: {Error}", contextMessage, finalMessage); break; // .NET checked-arithmetic overflow (e.g. numeric/date conversion in app code). // Distinguished from SqlException so this never gets mistaken for a database-side // (e.g. column precision) issue — the wording of both is nearly identical otherwise. case OverflowException overflowEx: finalMessage = $"Numeric conversion overflow in application code: {overflowEx.Message}"; _Logger.LogError(overflowEx, "[OVERFLOW ERROR - .NET] {Context}: {Error}", contextMessage, finalMessage); break; default: finalMessage = ex.InnerException?.Message ?? ex.Message; _Logger.LogError(ex, "[GENERAL ERROR] {Context}: {Error}", contextMessage, finalMessage); break; } // Optional: Log to database (if needed) await LogExceptionToDatabaseAsync(ex, contextMessage, finalMessage); return finalMessage; } catch (Exception handlerEx) { _Logger.LogCritical(handlerEx, "Critical error while handling exception."); return "Unexpected error while processing exception."; } } private async Task LogExceptionToDatabaseAsync(Exception ex, string context, string userMessage) { try { const string sql = @" INSERT INTO GB5_ExceptionLog (LogDate, ExceptionType, Context, Message, StackTrace) VALUES (GETDATE(), @Type, @Context, @Message, @StackTrace)"; var queryParams = new Dictionary { ["Type"] = ex.GetType().FullName ?? "UnknownException", ["Context"] = context ?? "N/A", ["Message"] = userMessage ?? ex.Message, ["StackTrace"] = ex.StackTrace ?? "", }; using var dbActivity = DatabaseActivityHelper.StartDbActivity(sql, parameters: queryParams); var connectionString = await _ApplicationConnection.Gb5SystemConnectionString(); await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new SqlCommand(sql, conn); cmd.Parameters.AddWithValue("@Type", queryParams["Type"]); cmd.Parameters.AddWithValue("@Context", queryParams["Context"]); cmd.Parameters.AddWithValue("@Message", queryParams["Message"]); cmd.Parameters.AddWithValue("@StackTrace", queryParams["StackTrace"]); await cmd.ExecuteNonQueryAsync(); dbActivity?.SetStatus(System.Diagnostics.ActivityStatusCode.Ok); } catch (Exception dbEx) { _Logger.LogError(dbEx, "Failed to log exception to database."); } } /// /// Extracts exact SQL Server message dynamically — fully generic. /// Example: /// "Cannot insert the value NULL into column 'CREATEDBYID', table 'dbo.MPERIOD'..." /// private static string ParseSqlError(SqlException ex) { if (ex.Errors.Count == 0) return ex.Message; var sb = new StringBuilder(); foreach (SqlError error in ex.Errors) { // SQL provides full descriptive text — we return that directly, tagged with the // error number/procedure/line so identical-looking messages (e.g. overflow) can // be traced back to the exact statement instead of being guessed at afterward. sb.Append(error.Message.Trim()); sb.Append(" [SQL Error ").Append(error.Number); if (!string.IsNullOrEmpty(error.Procedure)) sb.Append(", Procedure: ").Append(error.Procedure); sb.Append(", Line: ").Append(error.LineNumber).Append(']'); sb.Append(' '); } return sb.ToString().Trim(); } /// /// Helper to safely extract substring between two patterns. /// private static string? ExtractBetween(string input, string start, string end) { int startIndex = input.IndexOf(start, StringComparison.OrdinalIgnoreCase); if (startIndex < 0) return null; startIndex += start.Length; int endIndex = input.IndexOf(end, startIndex, StringComparison.OrdinalIgnoreCase); if (endIndex < 0) return null; return input[startIndex..endIndex]; } public static async Task GetConnectionStringAsync(LoginDTO LoginDTO) { try { if (LoginDTO == null) throw new ArgumentNullException(nameof(LoginDTO)); if (string.IsNullOrWhiteSpace(LoginDTO.ServerIP)) throw new ArgumentException("ServerIP cannot be null or empty.", nameof(LoginDTO.ServerIP)); if (string.IsNullOrWhiteSpace(LoginDTO.DatabaseName)) throw new ArgumentException("DatabaseName cannot be null or empty.", nameof(LoginDTO.DatabaseName)); // Simulate async for consistency string connectionString = $"Server={LoginDTO.ServerIP};Database={LoginDTO.DatabaseName};User Id=Developer;Password=devuser@123;TrustServerCertificate=True;"; return await Task.FromResult(connectionString); } catch (Exception ex) { throw new Exception("Error generating connection string: " + ex.Message, ex); } } private string GetFieldName(string? fieldName, object? value) { return !string.IsNullOrWhiteSpace(fieldName) ? fieldName : value?.GetType().Name ?? "UnknownField"; } } }