using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.Json; using System.Threading.Tasks; using CredentialManager.Models; using CredentialManager.Services; using DataConnection.CredentialManagement.Interfaces; using DataConnection.REST.Implementations; using DataConnection.REST.Interfaces; using Microsoft.Extensions.Logging; namespace Data_Coupler.Services; /// /// Servizio per la gestione delle associazioni tra record sorgente e destinazione. /// Include logica di Pre-Discovery per trovare record esistenti prima di creare duplicati. /// public class AssociationService : IAssociationService { private readonly IDataConnectionCredentialService _credentialService; private readonly ILogger _logger; public AssociationService( IDataConnectionCredentialService credentialService, ILogger logger) { _credentialService = credentialService ?? throw new ArgumentNullException(nameof(credentialService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// /// Trova o crea un'associazione tramite Pre-Discovery. /// Se non esiste un'associazione locale, cerca nella destinazione REST. /// Se trova un record, crea l'associazione automaticamente. /// /// Parametri per la ricerca/creazione associazione /// Associazione esistente o appena creata, null se non trovata public async Task FindOrCreateAssociationAsync(PreDiscoveryRequest request) { if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrEmpty(request.SourceKey)) { _logger.LogWarning("FindOrCreateAssociationAsync: SourceKey vuoto, skip"); return null; } // Step 1: Cerca associazione esistente var existingAssociation = await FindExistingAssociationAsync(request); if (existingAssociation != null) { _logger.LogDebug("Associazione esistente trovata: ID={AssociationId}, DestinationId={DestinationId}", existingAssociation.Id, existingAssociation.DestinationId); return existingAssociation; } // Step 2: Pre-Discovery nella destinazione REST if (request.EnablePreDiscovery) { var discoveredAssociation = await PerformPreDiscoveryAsync(request); if (discoveredAssociation != null) { return discoveredAssociation; } } // Non trovata né in locale né in destinazione return null; } /// /// Versione bulk del find-or-create — vedi . /// public async Task> BatchFindOrCreateAssociationsAsync( IEnumerable sourceKeys, PreDiscoveryRequest commonRequest) { if (commonRequest == null) throw new ArgumentNullException(nameof(commonRequest)); var distinctKeys = sourceKeys .Where(k => !string.IsNullOrEmpty(k)) .Distinct() .ToList(); var result = new Dictionary(StringComparer.Ordinal); if (distinctKeys.Count == 0) return result; // STEP 1 — Bulk lookup nel DB locale (1 query SQLite invece di N) Dictionary localMatches; try { localMatches = await _credentialService.FindKeyAssociationsByValuesBulkAsync( distinctKeys, commonRequest.DestinationEntity, commonRequest.CredentialName); } catch (Exception ex) { _logger.LogError(ex, "BULK PRE-DISCOVERY: bulk lookup locale fallito, fallback per-record"); // Fallback per-record (mantiene comportamento esistente in caso di problemi) foreach (var key in distinctKeys) { var req = ClonePreDiscoveryRequest(commonRequest, key); var found = await FindOrCreateAssociationAsync(req); if (found != null) result[key] = found; } return result; } foreach (var kvp in localMatches) result[kvp.Key] = kvp.Value; var missingKeys = distinctKeys.Where(k => !result.ContainsKey(k)).ToList(); _logger.LogInformation("BULK PRE-DISCOVERY: {Local}/{Total} associazioni trovate localmente, {Missing} da cercare nella destinazione", localMatches.Count, distinctKeys.Count, missingKeys.Count); if (missingKeys.Count == 0 || !commonRequest.EnablePreDiscovery) return result; // STEP 2 — Pre-Discovery batched sulla destinazione REST // Verifica che il campo chiave sia mappato if (string.IsNullOrEmpty(commonRequest.SourceKeyField) || !commonRequest.FieldMappings.TryGetValue(commonRequest.SourceKeyField, out var mappedField)) { _logger.LogWarning("BULK PRE-DISCOVERY: campo chiave '{SourceKeyField}' non mappato; skip discovery", commonRequest.SourceKeyField); return result; } // Solo SalesforceServiceClient supporta SOQL IN ottimizzata; per altri client si ricade al per-record. if (commonRequest.RestClient is SalesforceServiceClient sfClient) { var discovered = await PerformBulkPreDiscoverySalesforceAsync( sfClient, missingKeys, mappedField, commonRequest); foreach (var kvp in discovered) result[kvp.Key] = kvp.Value; } else { _logger.LogDebug("BULK PRE-DISCOVERY: client REST non Salesforce, fallback per-record per {Count} chiavi", missingKeys.Count); foreach (var key in missingKeys) { var req = ClonePreDiscoveryRequest(commonRequest, key); var found = await PerformPreDiscoveryAsync(req); if (found != null) result[key] = found; } } return result; } /// /// Pre-Discovery batched specifica per Salesforce: usa SOQL WHERE field IN (...) /// per recuperare in pochissime chiamate API tutti i record che matchano una qualsiasi delle chiavi mancanti. /// private async Task> PerformBulkPreDiscoverySalesforceAsync( SalesforceServiceClient sfClient, List missingKeys, string mappedDestinationField, PreDiscoveryRequest commonRequest) { var output = new Dictionary(StringComparer.Ordinal); // Chunk per stare sotto il limite SOQL/URL (~16 KB GET): ~200 valori per query const int chunkSize = 200; var queries = new List(); for (int i = 0; i < missingKeys.Count; i += chunkSize) { var chunk = missingKeys.Skip(i).Take(chunkSize).ToList(); var sb = new StringBuilder(); sb.Append("SELECT Id, "); sb.Append(mappedDestinationField); sb.Append(" FROM "); sb.Append(commonRequest.DestinationEntity); sb.Append(" WHERE "); sb.Append(mappedDestinationField); sb.Append(" IN ("); sb.Append(string.Join(",", chunk.Select(v => $"'{v.Replace("'", "\\'")}'"))); sb.Append(')'); queries.Add(sb.ToString()); } _logger.LogInformation("BULK PRE-DISCOVERY: {QueryCount} SOQL IN-query (~{ChunkSize} chiavi/query, Composite API ceil(N/25) HTTP call)", queries.Count, chunkSize); // BatchExecuteQueriesAsync raggruppa fino a 25 query in 1 Composite request var batchResults = await sfClient.BatchExecuteQueriesAsync(queries); // Indicizza i risultati per chiave: dal record letto leggiamo il valore di mappedDestinationField var entityIdByKey = new Dictionary(StringComparer.Ordinal); foreach (var batchResult in batchResults.Where(r => r.Success && r.Records != null)) { foreach (var record in batchResult.Records) { if (!record.TryGetValue(mappedDestinationField, out var keyVal) || keyVal == null) continue; var keyStr = keyVal.ToString(); if (string.IsNullOrEmpty(keyStr)) continue; var idStr = ExtractDestinationId(record); if (string.IsNullOrEmpty(idStr)) continue; // In caso di duplicati in Salesforce, prendiamo il primo if (!entityIdByKey.ContainsKey(keyStr)) entityIdByKey[keyStr] = idStr; } } _logger.LogInformation("BULK PRE-DISCOVERY: trovati {Found}/{Missing} record esistenti nella destinazione", entityIdByKey.Count, missingKeys.Count); if (entityIdByKey.Count == 0) return output; // Salvataggio associazioni Pre-Discovery in parallelo var saveTasks = entityIdByKey.Select(async kvp => { try { var newAssoc = new KeyAssociation { KeyValue = kvp.Key, SourceKeyField = commonRequest.SourceKeyField, DestinationKeyField = commonRequest.DestinationKeyField ?? "Id", MappedDestinationField = mappedDestinationField, DestinationEntity = commonRequest.DestinationEntity, DestinationId = kvp.Value, RestCredentialName = commonRequest.CredentialName, CreatedAt = DateTime.UtcNow, LastVerifiedAt = DateTime.UtcNow, IsActive = true, AdditionalInfo = JsonSerializer.Serialize(new Dictionary { { "CreatedBy", "PreDiscovery" }, { "DiscoveredAt", DateTime.UtcNow }, { "MappingCount", commonRequest.FieldMappings.Count }, { "BulkPreDiscovery", true }, { "ScheduledTransfer", commonRequest.IsScheduledTransfer }, { "SourceType", commonRequest.SourceType ?? string.Empty } }) }; var id = commonRequest.UseParallelMethod ? await _credentialService.SaveKeyAssociationParallelAsync(newAssoc) : await _credentialService.SaveKeyAssociationAsync(newAssoc); newAssoc.Id = id; return (kvp.Key, newAssoc); } catch (Exception ex) { _logger.LogWarning(ex, "BULK PRE-DISCOVERY: errore nel salvataggio associazione per KeyValue '{KeyValue}'", kvp.Key); return (kvp.Key, (KeyAssociation?)null); } }); var savedResults = await Task.WhenAll(saveTasks); foreach (var (key, assoc) in savedResults) { if (assoc != null) output[key] = assoc; } return output; } private static PreDiscoveryRequest ClonePreDiscoveryRequest(PreDiscoveryRequest source, string sourceKey) { return new PreDiscoveryRequest { SourceKey = sourceKey, SourceKeyField = source.SourceKeyField, DestinationEntity = source.DestinationEntity, CredentialName = source.CredentialName, DestinationKeyField = source.DestinationKeyField, FieldMappings = source.FieldMappings, RestClient = source.RestClient, CurrentDataHash = source.CurrentDataHash, EnablePreDiscovery = source.EnablePreDiscovery, UseParallelMethod = source.UseParallelMethod, IsScheduledTransfer = source.IsScheduledTransfer, SourceType = source.SourceType }; } /// /// Verifica se un'associazione è stata creata dal Pre-Discovery /// controllando il campo AdditionalInfo /// public bool IsPreDiscoveryAssociation(KeyAssociation association) { if (association == null || string.IsNullOrEmpty(association.AdditionalInfo)) return false; try { var additionalInfo = JsonSerializer.Deserialize>(association.AdditionalInfo); if (additionalInfo != null && additionalInfo.ContainsKey("CreatedBy")) { var createdBy = additionalInfo["CreatedBy"]?.ToString(); return createdBy == "PreDiscovery"; } } catch (Exception ex) { _logger.LogWarning(ex, "Errore parsing AdditionalInfo per AssociationId={Id}", association.Id); } return false; } #region Private Methods /// /// Cerca un'associazione esistente nel database locale /// private async Task FindExistingAssociationAsync(PreDiscoveryRequest request) { _logger.LogDebug("Cerco associazione per KeyValue='{KeyValue}', Entity='{Entity}', Credential='{Credential}'", request.SourceKey, request.DestinationEntity, request.CredentialName); // Cerca con tutti i parametri var association = request.UseParallelMethod ? await _credentialService.FindKeyAssociationByValueParallelAsync( request.SourceKey, request.DestinationEntity, request.CredentialName) : await _credentialService.FindKeyAssociationByValueAsync( request.SourceKey, request.DestinationEntity, request.CredentialName); // FALLBACK: Se non trovata, cerca solo per KeyValue if (association == null) { _logger.LogDebug("Associazione non trovata con parametri specifici, provo solo con KeyValue"); association = request.UseParallelMethod ? await _credentialService.FindKeyAssociationByValueParallelAsync(request.SourceKey) : await _credentialService.FindKeyAssociationByValueAsync(request.SourceKey); if (association != null) { // Verifica compatibilità if (association.DestinationEntity != request.DestinationEntity || association.RestCredentialName != request.CredentialName) { _logger.LogDebug("Associazione non compatibile: Entity={FoundEntity} vs {ExpectedEntity}, Credential={FoundCred} vs {ExpectedCred}", association.DestinationEntity, request.DestinationEntity, association.RestCredentialName, request.CredentialName); association = null; } } } return association; } /// /// Esegue il Pre-Discovery cercando nella destinazione REST /// private async Task PerformPreDiscoveryAsync(PreDiscoveryRequest request) { _logger.LogInformation("PRE-DISCOVERY: Nessuna associazione trovata per '{KeyValue}'. Cerco nella destinazione...", request.SourceKey); // Verifica che il campo chiave sia mappato if (!request.FieldMappings.TryGetValue(request.SourceKeyField, out var mappedDestinationFieldName)) { _logger.LogWarning("PRE-DISCOVERY: Campo chiave '{SourceKeyField}' non trovato nei mappings. Skip discovery.", request.SourceKeyField); return null; } try { // Prepara i campi di ricerca var searchFields = new Dictionary { { mappedDestinationFieldName, request.SourceKey } }; _logger.LogInformation("PRE-DISCOVERY: Cerco in '{Entity}' dove {Field} = '{Value}'", request.DestinationEntity, mappedDestinationFieldName, request.SourceKey); // Cerca nella destinazione REST var existingEntities = await request.RestClient.FindEntitiesByKeysAsync( request.DestinationEntity, searchFields); if (existingEntities == null || existingEntities.Count == 0) { _logger.LogInformation("PRE-DISCOVERY: Nessun record esistente trovato per KeyValue: '{KeyValue}'", request.SourceKey); return null; } // Trovato! Prendi il primo risultato var foundEntity = existingEntities[0]; // Estrai l'ID del record trovato var destinationId = ExtractDestinationId(foundEntity); if (string.IsNullOrEmpty(destinationId)) { _logger.LogWarning("PRE-DISCOVERY: Record trovato ma senza ID valido per KeyValue: '{KeyValue}'", request.SourceKey); return null; } _logger.LogInformation("PRE-DISCOVERY: ✅ Trovato record esistente! KeyValue: '{KeyValue}' -> DestinationId: '{DestinationId}'", request.SourceKey, destinationId); // Crea l'associazione var newAssociation = CreatePreDiscoveryAssociation( request, mappedDestinationFieldName, destinationId); // Salva l'associazione var associationId = request.UseParallelMethod ? await _credentialService.SaveKeyAssociationParallelAsync(newAssociation) : await _credentialService.SaveKeyAssociationAsync(newAssociation); _logger.LogInformation("PRE-DISCOVERY: Associazione creata con ID: {AssociationId}", associationId); newAssociation.Id = associationId; return newAssociation; } catch (Exception ex) { _logger.LogWarning(ex, "PRE-DISCOVERY: Errore durante la ricerca nella destinazione per KeyValue: '{KeyValue}'", request.SourceKey); return null; } } /// /// Estrae l'ID di destinazione dal record trovato /// private string? ExtractDestinationId(Dictionary entity) { // Prova "Id" (case-sensitive) if (entity.ContainsKey("Id")) return entity["Id"]?.ToString(); // Prova "id" (lowercase) if (entity.ContainsKey("id")) return entity["id"]?.ToString(); // Prova case-insensitive var idKey = entity.Keys.FirstOrDefault(k => k.Equals("Id", StringComparison.OrdinalIgnoreCase)); return idKey != null ? entity[idKey]?.ToString() : null; } /// /// Crea una nuova associazione con marker Pre-Discovery /// private KeyAssociation CreatePreDiscoveryAssociation( PreDiscoveryRequest request, string mappedDestinationFieldName, string destinationId) { var additionalInfo = new Dictionary { { "CreatedBy", "PreDiscovery" }, { "DiscoveredAt", DateTime.UtcNow }, { "MappingCount", request.FieldMappings.Count } }; // Aggiungi info su scheduled transfer se specificato if (request.IsScheduledTransfer) { additionalInfo.Add("ScheduledTransfer", true); } // Aggiungi source type se specificato if (!string.IsNullOrEmpty(request.SourceType)) { additionalInfo.Add("SourceType", request.SourceType); } return new KeyAssociation { KeyValue = request.SourceKey, SourceKeyField = request.SourceKeyField, DestinationKeyField = request.DestinationKeyField ?? "Id", MappedDestinationField = mappedDestinationFieldName, DestinationEntity = request.DestinationEntity, DestinationId = destinationId, RestCredentialName = request.CredentialName, CreatedAt = DateTime.UtcNow, LastVerifiedAt = DateTime.UtcNow, IsActive = true, Data_Hash = request.CurrentDataHash, AdditionalInfo = JsonSerializer.Serialize(additionalInfo) }; } #endregion } /// /// Interfaccia per il servizio di gestione associazioni /// public interface IAssociationService { Task FindOrCreateAssociationAsync(PreDiscoveryRequest request); bool IsPreDiscoveryAssociation(KeyAssociation association); /// /// Versione bulk del find-or-create. /// 1) Una sola query SQLite (WHERE KeyValue IN …) per recuperare le associazioni esistenti. /// 2) Per le chiavi non trovate localmente, una manciata di SOQL "IN" su Salesforce /// (~200 chiavi per query, Composite API: ceil(K/25) HTTP call) invece di K chiamate singole. /// 3) Le associazioni Pre-Discovery scoperte vengono salvate e restituite. /// /// Lista (non vuota) dei valori chiave sorgente per tutti i record da analizzare. /// Parametri condivisi (entity, credential, restClient, mappings, ecc.). /// e /// sono ignorati; vengono presi dal parametro . /// Dizionario KeyValue → KeyAssociation (solo per chiavi trovate/create). Task> BatchFindOrCreateAssociationsAsync( IEnumerable sourceKeys, PreDiscoveryRequest commonRequest); } /// /// Parametri per la ricerca/creazione di associazioni con Pre-Discovery /// public class PreDiscoveryRequest { /// /// Valore della chiave sorgente (es: "C00001") /// public string SourceKey { get; set; } = string.Empty; /// /// Nome del campo chiave nella sorgente (es: "CardCode") /// public string SourceKeyField { get; set; } = string.Empty; /// /// Nome dell'entità destinazione (es: "Account") /// public string DestinationEntity { get; set; } = string.Empty; /// /// Nome della credenziale REST (es: "Salesforce_Prod") /// public string CredentialName { get; set; } = string.Empty; /// /// Campo ID nella destinazione (default: "Id") /// public string? DestinationKeyField { get; set; } /// /// Mappings campo sorgente -> campo destinazione /// public Dictionary FieldMappings { get; set; } = new(); /// /// Client REST per effettuare la ricerca nella destinazione /// public IRestServiceClient RestClient { get; set; } = null!; /// /// Hash dei dati correnti da salvare nell'associazione /// public string? CurrentDataHash { get; set; } /// /// Se abilitare il Pre-Discovery (default: true) /// public bool EnablePreDiscovery { get; set; } = true; /// /// Se usare i metodi paralleli thread-safe (default: false) /// public bool UseParallelMethod { get; set; } = false; /// /// Se la richiesta proviene da un trasferimento schedulato /// public bool IsScheduledTransfer { get; set; } = false; /// /// Tipo di sorgente (database, file, etc.) /// public string? SourceType { get; set; } }