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; }
}