using EAIAdminBLL.Sync; using EAIAdminDAL.CustomCode; using EAIAdminDAL.DTO; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.Response; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace EAIAdminBLL.EAIModelConfigAdmin { public class EAIModelConfigAdminBLL : IEAIModelConfigAdminBLL { private static readonly Regex ModelCodePattern = new(@"^[A-Za-z0-9_]{1,50}$", RegexOptions.Compiled); private readonly IEAIModelConfigAdminDAL _dal; private readonly IEAISyncClient _syncClient; private readonly ILogger _logger; public EAIModelConfigAdminBLL( IEAIModelConfigAdminDAL dal, IEAISyncClient syncClient, ILogger logger) { _dal = dal ?? throw new ArgumentNullException(nameof(dal)); _syncClient = syncClient ?? throw new ArgumentNullException(nameof(syncClient)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task> GetModelConfigListAsync( string? search, LoginDTO login, CancellationToken ct = default) { try { return await _dal.GetModelConfigListAsync(search, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "BLL | GetModelConfigList | Error | Tenant={TenantId}", login.ClientId); throw; } } public async Task GetModelConfigAsync( int modelId, LoginDTO login, CancellationToken ct = default) { try { return await _dal.GetModelConfigAsync(modelId, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "BLL | GetModelConfig | Error | ModelId={Id}", modelId); throw; } } public async Task SaveModelConfigAsync( EAIModelConfigAdminDTO dto, LoginDTO login, CancellationToken ct = default) { try { ValidateModelCode(dto.ModelCode); if (string.IsNullOrWhiteSpace(dto.ModelName)) throw new ArgumentException("ModelName is required."); if (string.IsNullOrWhiteSpace(dto.Provider)) throw new ArgumentException("Provider is required."); // MODELCODE is unique per (TenantId, ModelCode) — matches // UK_MEAIMODELCONFIG_TENANT_MODELCODE, unlike MEAIINITIATIVE this IS per-tenant. bool codeExists = await _dal.ModelCodeExistsAsync( dto.ModelCode, dto.ModelId, login, ct).ConfigureAwait(false); if (codeExists) throw new InvalidOperationException( $"ModelCode '{dto.ModelCode}' already exists."); dto.TenantId = login.ClientId; var isNew = dto.ModelId == 0; if (isNew) { // ApiKeyVaultKey is a Vault PATH, never a raw client-supplied secret. On insert // only, auto-generate a deterministic path when the client didn't supply one. if (string.IsNullOrWhiteSpace(dto.ApiKeyVaultKey)) dto.ApiKeyVaultKey = $"eai/model/{login.ClientId}/{dto.ModelCode}/apikey"; var newId = await _dal.InsertModelConfigAsync(dto, login, ct).ConfigureAwait(false); dto.ModelId = newId; } else { // Never let the client overwrite ApiKeyVaultKey from the wire on update — the // actual secret behind that path is only ever changed through the separate // Vault "Set API Key" UI action, not through this Save endpoint. Re-fetch the // existing row's value and keep it as-is regardless of what the client sent. var existing = await _dal.GetModelConfigAsync(dto.ModelId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"ModelConfig {dto.ModelId} not found."); dto.ApiKeyVaultKey = existing.ApiKeyVaultKey; await _dal.UpdateModelConfigAsync(dto, login, ct).ConfigureAwait(false); } // Push to gbEAI AFTER the local commit — never inside a transaction. A failed push // does not roll back the local save; GB5 stays system-of-record. var syncResult = await _syncClient.PushModelConfigAsync(dto, login, ct).ConfigureAwait(false); if (syncResult.Success && syncResult.EAIRemoteId is { } remoteId) { await _dal.UpdateEAIRemoteIdAsync(dto.ModelId, remoteId, login, ct).ConfigureAwait(false); } var baseMessage = isNew ? $"{SuccessResponse.SaveSuccessMessage} {dto.ModelId}" : SuccessResponse.UpdateSuccess; return syncResult.Success ? baseMessage : $"{baseMessage} Sync to AI-Enterprise-v1.0 failed: {syncResult.Message} — retry from this screen."; } catch (Exception ex) { _logger.LogError(ex, "BLL | SaveModelConfig | Error | Tenant={TenantId} | ModelCode={Code}", login.ClientId, dto.ModelCode); throw; } } public async Task DeleteModelConfigAsync( int modelId, LoginDTO login, CancellationToken ct = default) { try { if (modelId == 0) throw new ArgumentException("ModelId must be a positive integer."); var existing = await _dal.GetModelConfigAsync(modelId, login, ct).ConfigureAwait(false) ?? throw new InvalidOperationException($"ModelConfig {modelId} not found."); await _dal.DeleteModelConfigAsync(modelId, login, ct).ConfigureAwait(false); var syncResult = await _syncClient.DeleteModelConfigAsync(existing, login, ct).ConfigureAwait(false); return syncResult.Success ? SuccessResponse.DeleteSuccessMessage : $"{SuccessResponse.DeleteSuccessMessage} Sync to AI-Enterprise-v1.0 failed: {syncResult.Message} — retry from this screen."; } catch (Exception ex) { _logger.LogError(ex, "BLL | DeleteModelConfig | Error | ModelId={Id}", modelId); throw; } } private static void ValidateModelCode(string modelCode) { if (string.IsNullOrWhiteSpace(modelCode)) throw new ArgumentException("ModelCode is required."); if (!ModelCodePattern.IsMatch(modelCode)) throw new ArgumentException( "ModelCode must contain only alphanumeric characters and underscores (max 50 chars)."); } } }