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; // Stashes the ambiguous MatchResult while the user is drilling into // one of its candidates, so the "→ חזור" button can restore it. private MatchResult? _ambiguousReturnTarget; [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 ObservableCollection Candidates { 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); /// /// True when the matched contact has 2+ linked cases. The sidebar /// uses this to hide the bottom "תייק לתיק הזה" button (which is /// ambiguous in that scenario) and to surface the per-row "תייק" /// buttons that let the user pick which case to file to. /// public Visibility MultiCaseHintVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count >= 2); public Visibility SingleCaseFileVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 1); /// /// "→ חזור לרשימת אנשי קשר" is only shown when the user drilled /// into a candidate from the ambiguous picker; not in a normal /// single-match lookup. /// public Visibility BackToCandidatesVisibility => Vis(State == ReadingPaneState.Match && _ambiguousReturnTarget != null); /// /// 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)); OnPropertyChanged(nameof(MultiCaseHintVisibility)); OnPropertyChanged(nameof(SingleCaseFileVisibility)); OnPropertyChanged(nameof(BackToCandidatesVisibility)); 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); } Candidates.Clear(); if (value?.Candidates != null) { foreach (var c in value.Candidates) Candidates.Add(c); } OnPropertyChanged(nameof(AccountVisibility)); OnPropertyChanged(nameof(NoCasesVisibility)); OnPropertyChanged(nameof(MultiCaseHintVisibility)); OnPropertyChanged(nameof(SingleCaseFileVisibility)); OnPropertyChanged(nameof(BackToCandidatesVisibility)); } public void Reset() { CancelInFlight(); SenderEmail = string.Empty; Match = null; ErrorMessage = null; AmbiguousMessage = null; _ambiguousReturnTarget = null; State = ReadingPaneState.Empty; } public async Task LoadAsync(string senderEmail) { CancelInFlight(); if (string.IsNullOrWhiteSpace(senderEmail)) { Reset(); return; } SenderEmail = senderEmail; // New email selection — drop any stashed candidate-list state. _ambiguousReturnTarget = null; 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!)); } // Bottom "תייק לתיק הזה" only makes sense with exactly one linked // case. 0 cases → nothing to file to; 2+ cases → ambiguous, the // per-row "תייק" buttons handle the pick. private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null && RecentCases.Count == 1; [RelayCommand] private void FileToCase(CaseEntity? caseEntity) { if (caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id) || Match == null) return; // Build a synthetic MatchResult so the host's // TryResolveSidebarAutoTarget path sees exactly one case and // auto-files without re-opening the search dialog. var synthetic = new MatchResult { SenderEmail = Match.SenderEmail, Contact = Match.Contact, Account = Match.Account, RecentCases = new System.Collections.Generic.List { caseEntity }, CandidateCount = 1 }; FileRequested?.Invoke(this, new FileFromSidebarEventArgs(synthetic)); } [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 OpenCaseInCrm(CaseEntity? caseEntity) { if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id)) return; var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Case/view/" + caseEntity.Id; OpenInBrowserRequested?.Invoke(this, url); } [RelayCommand] private void OpenManualSearch() { FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); } [RelayCommand] private void BackToCandidates() { if (_ambiguousReturnTarget == null) return; var stash = _ambiguousReturnTarget; // Clear stash before we re-enter Ambiguous so visibility refresh // (triggered by OnMatchChanged) sees the cleared state and hides // the back button until the user drills in again. _ambiguousReturnTarget = null; Match = stash; AmbiguousMessage = $"נמצאו {stash.CandidateCount} אנשי קשר עם הכתובת הזו."; State = ReadingPaneState.Ambiguous; } /// /// User picked one of the ambiguous candidates from the sidebar. /// Load that contact's full bundle (account + cases) and transition /// to the Match state so the user can pick a case or file. /// [RelayCommand] private async Task SelectCandidateAsync(ContactEntity? candidate) { if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return; CancelInFlight(); var cts = new CancellationTokenSource(); _activeLookup = cts; var rememberedEmail = SenderEmail; // Stash the ambiguous result so "→ חזור" can restore it. if (Match?.IsAmbiguous == true) { _ambiguousReturnTarget = Match; } State = ReadingPaneState.Loading; try { var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true); if (cts.IsCancellationRequested) return; if (result == null || result.Contact == null) { ErrorMessage = "טעינת איש הקשר נכשלה."; State = ReadingPaneState.Error; 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.SelectCandidateAsync failed for {ContactId}", candidate.Id); ErrorMessage = "שגיאת התחברות לשרת."; State = ReadingPaneState.Error; } } 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; } } }