using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using MarcusLaw.OutlookAddin.Core.Models; using Microsoft.Extensions.Caching.Memory; using Serilog; namespace MarcusLaw.OutlookAddin.Core.Services { public sealed class MatchingService : IMatchingService { private static readonly TimeSpan CacheExpiry = TimeSpan.FromMinutes(10); private const string ContactSelect = "id,name,emailAddress,accountId,accountName"; private readonly IEspoCrmClient _client; private readonly IMemoryCache _cache; private readonly ILogger _logger; public MatchingService(IEspoCrmClient client, IMemoryCache cache, ILogger logger) { _client = client ?? throw new ArgumentNullException(nameof(client)); _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task LookupAsync(string senderEmail, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(senderEmail)) return null; if (!senderEmail.Contains("@")) return null; var key = CacheKey(senderEmail); if (_cache.TryGetValue(key, out var cached)) { return cached; } try { var matches = await _client.EmailAddressSearchAsync(senderEmail, "Contact", cancellationToken).ConfigureAwait(false); if (matches.Count == 0) { _cache.Set(key, (MatchResult?)null, CacheExpiry); return null; } // EspoCRM's /EmailAddress/search can return the same Contact // more than once when the contact has the searched-for // address registered under multiple emailAddressData rows. // Dedupe by Id so the sidebar shows one row per unique person. var distinctIds = matches .Where(m => !string.IsNullOrWhiteSpace(m.Id)) .Select(m => m.Id) .Distinct(StringComparer.Ordinal) .Take(20) .ToList(); if (distinctIds.Count == 0) { _cache.Set(key, (MatchResult?)null, CacheExpiry); return null; } if (distinctIds.Count == 1) { var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false); _cache.Set(key, single, CacheExpiry); return single; } // Two or more genuinely distinct contacts. Fetch each one's // full record (in parallel) so the picker can show real // name + account, not just the bare email payload. var fetched = await Task.WhenAll( distinctIds.Select(id => SafeGetContactAsync(id, cancellationToken)) ).ConfigureAwait(false); var candidates = fetched .Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id)) .Select(c => c!) .ToList(); var ambiguous = new MatchResult { SenderEmail = senderEmail, IsAmbiguous = true, CandidateCount = candidates.Count, Candidates = candidates }; _cache.Set(key, ambiguous, CacheExpiry); return ambiguous; } catch (EspoCrmAuthorizationException) { throw; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail); return null; } } public async Task LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(contactId)) return null; try { return await LoadContactBundleAsync(contactId, senderEmail ?? string.Empty, cancellationToken).ConfigureAwait(false); } catch (EspoCrmAuthorizationException) { throw; } catch (OperationCanceledException) { throw; } catch (Exception ex) { _logger.Warning(ex, "MatchingService: LookupContactAsync failed for {ContactId}", contactId); return null; } } private async Task LoadContactBundleAsync(string contactId, string senderEmail, CancellationToken cancellationToken) { var contact = await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false); AccountEntity? account = null; if (!string.IsNullOrWhiteSpace(contact.AccountId)) { try { account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.Warning(ex, "MatchingService: failed to load account {AccountId} for contact {ContactId}", contact.AccountId, contactId); } } var cases = new System.Collections.Generic.List(); try { // Pull up to 50 cases per contact so the sidebar can show the // whole list, not just the 5 most recent. 50 is well below the // EspoCRM default page limit and large enough for any realistic // attorney/contact relationship. var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false); cases.AddRange(list.List); } catch (Exception ex) { _logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id); } return new MatchResult { SenderEmail = senderEmail, Contact = contact, Account = account, RecentCases = cases, CandidateCount = 1 }; } private async Task SafeGetContactAsync(string contactId, CancellationToken cancellationToken) { try { return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } catch (EspoCrmAuthorizationException) { throw; } catch (Exception ex) { _logger.Warning(ex, "MatchingService: candidate contact fetch failed for {Id}", contactId); return null; } } public void Invalidate(string senderEmail) { if (string.IsNullOrWhiteSpace(senderEmail)) return; _cache.Remove(CacheKey(senderEmail)); } private static string CacheKey(string senderEmail) => "match:" + senderEmail.Trim().ToLowerInvariant(); } }