using System; using System.Collections.ObjectModel; using System.Threading; using System.Threading.Tasks; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MarcusLaw.OutlookAddin.Core.Models; using MarcusLaw.OutlookAddin.Core.Services; using Serilog; namespace MarcusLaw.OutlookAddin.UI.ViewModels { /// /// Possible states of the sidebar. Each maps to a Visibility binding /// in ReadingPaneView.xaml. /// public enum ReadingPaneState { Empty, Loading, Match, NoMatch, Ambiguous, Error } public sealed partial class ReadingPaneViewModel : ObservableObject { private readonly IMatchingService _matchingService; private readonly ILogger _logger; private CancellationTokenSource? _activeLookup; [ObservableProperty] private ReadingPaneState state = ReadingPaneState.Empty; [ObservableProperty] private string senderEmail = string.Empty; [ObservableProperty] private MatchResult? match; [ObservableProperty] private string? errorMessage; [ObservableProperty] private string? ambiguousMessage; [ObservableProperty] private string? espoCrmBaseUrl; public ObservableCollection RecentCases { get; } = new ObservableCollection(); public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty); public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading); public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match); public Visibility NoMatchVisibility => Vis(State == ReadingPaneState.NoMatch); public Visibility AmbiguousVisibility => Vis(State == ReadingPaneState.Ambiguous); public Visibility ErrorVisibility => Vis(State == ReadingPaneState.Error); public Visibility AccountVisibility => Vis(Match?.Account != null); public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0); public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match); /// /// Raised when the user clicks "File to this case" (carries the chosen /// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null). /// Host wires this to AddInHost.LaunchFileToCaseAsync. /// public event EventHandler? FileRequested; /// /// Raised when the user clicks "Open in EspoCRM". The host opens the URL. /// public event EventHandler? OpenInBrowserRequested; public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger) { _matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } partial void OnStateChanged(ReadingPaneState value) { OnPropertyChanged(nameof(EmptyVisibility)); OnPropertyChanged(nameof(LoadingVisibility)); OnPropertyChanged(nameof(MatchVisibility)); OnPropertyChanged(nameof(NoMatchVisibility)); OnPropertyChanged(nameof(AmbiguousVisibility)); OnPropertyChanged(nameof(ErrorVisibility)); OnPropertyChanged(nameof(ActionsVisibility)); OnPropertyChanged(nameof(NoCasesVisibility)); FileToThisCommand.NotifyCanExecuteChanged(); FileElsewhereCommand.NotifyCanExecuteChanged(); OpenInEspoCrmCommand.NotifyCanExecuteChanged(); } partial void OnMatchChanged(MatchResult? value) { RecentCases.Clear(); if (value?.RecentCases != null) { foreach (var c in value.RecentCases) RecentCases.Add(c); } OnPropertyChanged(nameof(AccountVisibility)); OnPropertyChanged(nameof(NoCasesVisibility)); } public void Reset() { CancelInFlight(); SenderEmail = string.Empty; Match = null; ErrorMessage = null; AmbiguousMessage = null; State = ReadingPaneState.Empty; } public async Task LoadAsync(string senderEmail) { CancelInFlight(); if (string.IsNullOrWhiteSpace(senderEmail)) { Reset(); return; } SenderEmail = senderEmail; State = ReadingPaneState.Loading; var cts = new CancellationTokenSource(); _activeLookup = cts; try { var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true); if (cts.IsCancellationRequested) return; if (result == null) { Match = null; State = ReadingPaneState.NoMatch; return; } if (result.IsAmbiguous) { Match = result; AmbiguousMessage = $"נמצאו {result.CandidateCount} אנשי קשר עם הכתובת הזו — נדרשת בחירה ידנית."; State = ReadingPaneState.Ambiguous; return; } Match = result; State = ReadingPaneState.Match; } catch (OperationCanceledException) { // Selection changed before lookup finished — ignore. } catch (EspoCrmAuthorizationException) { ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות."; State = ReadingPaneState.Error; } catch (Exception ex) { _logger.Warning(ex, "ReadingPaneViewModel.LoadAsync failed for {Email}", senderEmail); ErrorMessage = "שגיאת התחברות לשרת."; State = ReadingPaneState.Error; } } private void CancelInFlight() { try { _activeLookup?.Cancel(); } catch { /* ignore */ } _activeLookup = null; } [RelayCommand(CanExecute = nameof(CanFileToThis))] private void FileToThis() { FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!)); } private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null; [RelayCommand(CanExecute = nameof(CanFileElsewhere))] private void FileElsewhere() { FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); } private bool CanFileElsewhere() => true; [RelayCommand(CanExecute = nameof(CanOpenInCrm))] private void OpenInEspoCrm() { if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || Match?.Contact == null) return; var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Contact/view/" + Match.Contact.Id; OpenInBrowserRequested?.Invoke(this, url); } private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null; [RelayCommand] private void OpenManualSearch() { FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); } private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed; } public sealed class FileFromSidebarEventArgs : EventArgs { public MatchResult? PreselectedMatch { get; } public FileFromSidebarEventArgs(MatchResult? preselected) { PreselectedMatch = preselected; } } }