// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI.Dispatching;
using Windows.Foundation;

namespace Microsoft.CmdPal.UI.ViewModels;

public partial class ListViewModel : PageViewModel, IDisposable
{
    public const int IncrementalRefresh = -2;

    private static readonly IEqualityComparer<IListItem> VmCacheComparer = new ProxyReferenceEqualityComparer();

    private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);

    private Dictionary<IListItem, ListItemViewModel> _vmCache = new(VmCacheComparer);

    // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?

    // Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
    // https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support
    public ObservableCollection<ListItemViewModel> FilteredItems { get; } = [];

    public FiltersViewModel? Filters { get; set; }

    private ObservableCollection<ListItemViewModel> Items { get; set; } = [];

    private readonly ExtensionObject<IListPage> _model;

    private readonly Lock _fetchStateLock = new();
    private readonly Lock _listLock = new();
    private readonly IContextMenuFactory _contextMenuFactory;

    [ThreadStatic]
    private static Dictionary<ListViewModel, int>? _getItemsDepthByViewModel;

    private InterlockedBoolean _isLoading;
    private int _activeFetchCount;
    private int _latestFetchGeneration;
    private bool _deferredFetchRequested;
    private bool _deferredFetchKeepSelection = true;

    public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;

    public bool ShowEmptyContent =>
        IsInitialized &&
        FilteredItems.Count == 0 &&
        !IsFetching &&
        !IsLoading;

    public bool IsGridView { get; private set; }

    public IGridPropertiesViewModel? GridProperties { get; private set; }

    private bool IsFetching => Volatile.Read(ref _activeFetchCount) > 0;

    // Remember - "observable" properties from the model (via PropChanged)
    // cannot be marked [ObservableProperty]
    public bool ShowDetails { get; private set; }

    private string _modelPlaceholderText = string.Empty;

    public override string PlaceholderText => _modelPlaceholderText;

    public string SearchText { get; private set; } = string.Empty;

    public string InitialSearchText { get; private set; } = string.Empty;

    public CommandItemViewModel EmptyContent { get; private set; }

    public bool IsMainPage { get; init; }

    public bool HasCustomDebounceLogic => IsMainPage;

    private bool _isDynamic;

    private Task? _initializeItemsTask;

    // For cancelling the task to load the properties from the items in the list
    private CancellationTokenSource? _cancellationTokenSource;

    // For cancelling the task for calling GetItems on the extension
    private CancellationTokenSource? _fetchItemsCancellationTokenSource;

    // For cancelling ongoing calls to update the extension's SearchText
    private CancellationTokenSource? filterCancellationTokenSource;

    private ListItemViewModel? _lastSelectedItem;

    // Persists across cancelled FetchItems calls so a forceFirstItem=true
    // intent is never lost when FetchItems(false) is cancelled by a
    // subsequent FetchItems(true).
    private volatile bool _forceFirstItemPending;

    // For cancelling a deferred SafeSlowInit when the user navigates rapidly
    private CancellationTokenSource? _selectedItemCts;

    public override bool IsInitialized
    {
        get => base.IsInitialized; protected set
        {
            base.IsInitialized = value;
            UpdateEmptyContent();
        }
    }

    public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
        : base(model, scheduler, host, providerContext)
    {
        _model = new(model);
        _contextMenuFactory = contextMenuFactory;
        EmptyContent = new(new(null), PageContext, contextMenuFactory: null);
    }

    private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        if (e.PropertyName == nameof(FiltersViewModel.Filters))
        {
            var filtersViewModel = sender as FiltersViewModel;
            var hasFilters = filtersViewModel?.Filters.Length > 0;
            HasFilters = hasFilters;
            UpdateProperty(nameof(HasFilters));
        }
    }

    private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args)
    {
        RequestFetch(args.TotalItems == IncrementalRefresh);
    }

    protected override void OnSearchTextBoxUpdated(string searchTextBox)
    {
        // Dynamic pages will handler their own filtering. They will tell us if
        // something needs to change, by raising ItemsChanged.
        if (_isDynamic)
        {
            CancelAndDisposeTokenSource(ref filterCancellationTokenSource);
            var filterCts = filterCancellationTokenSource = new CancellationTokenSource();
            var filterToken = filterCts.Token;

            // Hop off to an exclusive scheduler background thread to update the
            // extension. We do this to ensure that all filter update requests
            // are serialized and in-order, so providers know to cancel previous
            // requests when a new one comes in. Otherwise, they may execute
            // concurrently.
            _ = filterTaskFactory.StartNew(
                () =>
                {
                    filterToken.ThrowIfCancellationRequested();

                    try
                    {
                        if (_model.Unsafe is IDynamicListPage dynamic)
                        {
                            dynamic.SearchText = searchTextBox;
                        }
                    }
                    catch (OperationCanceledException)
                    {
                    }
                    catch (Exception ex)
                    {
                        ShowException(ex, _model?.Unsafe?.Name);
                    }
                },
                filterToken,
                TaskCreationOptions.None,
                filterTaskFactory.Scheduler!);
        }
        else
        {
            // But for all normal pages, we should run our fuzzy match on them.
            lock (_listLock)
            {
                ApplyFilterUnderLock();
            }

            ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
            UpdateEmptyContent();
            _isLoading.Clear();
        }
    }

    public void UpdateCurrentFilter(string currentFilterId)
    {
        // We're getting called on the UI thread.
        // Hop off to a BG thread to update the extension.
        _ = Task.Run(() =>
        {
            try
            {
                if (_model.Unsafe is IListPage listPage)
                {
                    listPage.Filters?.CurrentFilterId = currentFilterId;
                }
            }
            catch (Exception ex)
            {
                ShowException(ex, _model?.Unsafe?.Name);
            }
        });
    }

    private void RequestFetch(bool keepSelection)
    {
        // Keep RPC GetItems work off the UI thread. If the provider raises
        // ItemsChanged while we're already on a background thread, stay on that
        // thread so same-thread reentrancy detection still works.
        if (IsCurrentThreadUiThread())
        {
            QueueObservedBackgroundFetch(() => RequestFetch(keepSelection), "Failed to request background fetch");
            return;
        }

        if (IsGetItemsActiveOnCurrentThread())
        {
            lock (_fetchStateLock)
            {
                _deferredFetchRequested = true;
                _deferredFetchKeepSelection &= keepSelection;
            }

            return;
        }

        FetchItems(keepSelection);
    }

    private void QueueDeferredFetchIfNeeded()
    {
        bool deferredFetchRequested;
        bool keepSelection;
        lock (_fetchStateLock)
        {
            deferredFetchRequested = _deferredFetchRequested;
            keepSelection = _deferredFetchKeepSelection;
            _deferredFetchRequested = false;
            _deferredFetchKeepSelection = true;
        }

        if (deferredFetchRequested)
        {
            QueueObservedBackgroundFetch(() => FetchItems(keepSelection), "Failed to execute deferred fetch");
        }
    }

    private static void QueueObservedBackgroundFetch(Action action, string logMessage)
    {
        _ = Task.Run(
            () =>
            {
                try
                {
                    action();
                }
                catch (OperationCanceledException)
                {
                }
                catch (Exception ex)
                {
                    CoreLogger.LogError(logMessage, ex);
                }
            });
    }

    //// Run on background thread, from InitializeAsync or Model_ItemsChanged
    private void FetchItems(bool keepSelection)
    {
        System.Diagnostics.Debug.Assert(!IsCurrentThreadUiThread(), "FetchItems should not run on the UI thread.");

        // If this fetch should reset selection, remember that intent even if
        // a later incremental fetch cancels us.
        if (!keepSelection)
        {
            _forceFirstItemPending = true;
        }

        CancellationToken cancellationToken;
        int fetchGeneration;
        lock (_fetchStateLock)
        {
            // Cancel any previous FetchItems operation
            CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
            _fetchItemsCancellationTokenSource = new CancellationTokenSource();

            cancellationToken = _fetchItemsCancellationTokenSource.Token;
            fetchGeneration = Interlocked.Increment(ref _latestFetchGeneration);
        }

        // Declared outside try so catch blocks can reference them
        List<ListItemViewModel> createdViewModels = [];
        var itemsTransferredToList = false;
        var fetchCountIncremented = false;

        try
        {
            fetchCountIncremented = true;
            if (Interlocked.Increment(ref _activeFetchCount) == 1)
            {
                UpdateEmptyContent();
            }

            ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

            IListItem[] newItems;
            try
            {
                EnterGetItemsScope();
                newItems = _model.Unsafe!.GetItems();
            }
            finally
            {
                ExitGetItemsScope();
            }

            ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

            // Collect all the items into new viewmodels
            List<ListItemViewModel> newViewModels = new(newItems.Length);
            var currentCache = ReadVmCache();
            var nextCache = new Dictionary<IListItem, ListItemViewModel>(newItems.Length, VmCacheComparer);
            var showsTitle = GridProperties?.ShowTitle ?? true;
            var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
            var created = 0;
            var reused = 0;
            foreach (var item in newItems)
            {
                try
                {
                    if (item is null)
                    {
                        continue;
                    }

                    ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

                    if (nextCache.TryGetValue(item, out var existing) || currentCache.TryGetValue(item, out existing))
                    {
                        existing.LayoutShowsTitle = showsTitle;
                        existing.LayoutShowsSubtitle = showsSubtitle;
                        newViewModels.Add(existing);
                        nextCache[item] = existing;
                        reused++;
                        continue;
                    }

                    var viewModel = new ListItemViewModel(item, new(this), _contextMenuFactory);

                    // If an item fails to load, silently ignore it.
                    if (viewModel.SafeFastInit())
                    {
                        viewModel.LayoutShowsTitle = showsTitle;
                        viewModel.LayoutShowsSubtitle = showsSubtitle;

                        newViewModels.Add(viewModel);
                        createdViewModels.Add(viewModel);
                        nextCache[item] = viewModel;
                        created++;
                    }
                }
                catch (OperationCanceledException)
                {
                    // Our own stale/cancel checks throw OCE to stop the whole fetch
                    // promptly. Only swallow item-local cancellations.
                    ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);
                    CoreLogger.LogDebug("Item load cancelled during fetch");
                }
                catch (Exception ex)
                {
                    CoreLogger.LogError("Failed to load item:\n", ex);
                }
            }

#if DEBUG
            CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {nextCache.Count} cached");
#endif

            ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

            var firstTwenty = newViewModels.Take(20);
            foreach (var item in firstTwenty)
            {
                ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

                item?.SafeInitializeProperties();
            }

            // Cancel any ongoing property initialization for the previous list.
            CancelAndDisposeTokenSource(ref _cancellationTokenSource);

            ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

            List<ListItemViewModel> removedItems;
            lock (_fetchStateLock)
            {
                ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken);

                lock (_listLock)
                {
                    // Now that we have new ViewModels for everything from the
                    // extension, smartly update our list of VMs
                    ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);

                    PublishVmCache(nextCache);

                    // DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
                    // you'll clean up list items that we've now transferred into
                    // .Items
                }
            }

            itemsTransferredToList = true;

            // If we removed items, we need to clean them up, to remove our event handlers
            foreach (var removedItem in removedItems)
            {
                removedItem.SafeCleanup();
            }
        }
        catch (OperationCanceledException)
        {
            // Cancellation is expected, don't treat as error

            // However, if we were cancelled, we didn't actually add these items to
            // our Items list. Before we release them to the GC, make sure we clean
            // them up
            if (!itemsTransferredToList)
            {
                foreach (var vm in createdViewModels)
                {
                    vm.SafeCleanup();
                }
            }

            return;
        }
        catch (Exception ex)
        {
            // TODO: Move this within the for loop, so we can catch issues with individual items
            // Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently.
            if (!itemsTransferredToList)
            {
                foreach (var vm in createdViewModels)
                {
                    vm.SafeCleanup();
                }
            }

            ShowException(ex, _model?.Unsafe?.Name);
            throw;
        }
        finally
        {
            if (fetchCountIncremented && Interlocked.Decrement(ref _activeFetchCount) == 0)
            {
                UpdateEmptyContent();
            }
        }

        var initializeItemsCts = new CancellationTokenSource();
        _cancellationTokenSource = initializeItemsCts;
        var initializeItemsToken = initializeItemsCts.Token;

        _initializeItemsTask = new Task(() =>
        {
            InitializeItemsTask(initializeItemsToken);
        });
        _initializeItemsTask.Start();

        DoOnUiThread(
            () =>
            {
                lock (_fetchStateLock)
                {
                    if (!IsLatestFetchGeneration(fetchGeneration))
                    {
                        return;
                    }

                    lock (_listLock)
                    {
                        if (!IsLatestFetchGeneration(fetchGeneration))
                        {
                            return;
                        }

                        // Now that our Items contains everything we want, it's time for us to
                        // re-evaluate our Filter on those items.
                        if (!_isDynamic)
                        {
                            // A static list? Great! Just run the filter.
                            ApplyFilterUnderLock();
                        }
                        else
                        {
                            // A dynamic list? Even better! Just stick everything into
                            // FilteredItems. The extension already did any filtering it cared about.
                            var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
                            ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
                        }

                        UpdateEmptyContent();
                    }

                    if (!IsLatestFetchGeneration(fetchGeneration))
                    {
                        return;
                    }

                    // Consume the pending flag on the UI thread so a
                    // forceFirstItem=true intent survives cancellation.
                    var forceFirst = _forceFirstItemPending;
                    _forceFirstItemPending = false;

                    ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(forceFirstItem: IsRootPage && forceFirst));
                    _isLoading.Clear();
                }
            });
    }

    private void InitializeItemsTask(CancellationToken ct)
    {
        // Were we already canceled?
        if (ct.IsCancellationRequested)
        {
            return;
        }

        ListItemViewModel[] iterable;
        lock (_listLock)
        {
            iterable = Items.ToArray();
        }

        foreach (var item in iterable)
        {
            if (ct.IsCancellationRequested)
            {
                return;
            }

            // TODO: GH #502
            // We should probably remove the item from the list if it
            // entered the error state. I had issues doing that without having
            // multiple threads muck with `Items` (and possibly FilteredItems!)
            // at once.
            item.SafeInitializeProperties();

            if (ct.IsCancellationRequested)
            {
                return;
            }
        }
    }

    /// <summary>
    /// Apply our current filter text to the list of items, and update
    /// FilteredItems to match the results.
    /// </summary>
    private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox));

    private Dictionary<IListItem, ListItemViewModel> ReadVmCache() => Volatile.Read(ref _vmCache);

    private static bool IsCurrentThreadUiThread()
    {
        try
        {
            return DispatcherQueue.GetForCurrentThread()?.HasThreadAccess == true;
        }
        catch (COMException)
        {
            return false;
        }
    }

    /// <summary>
    /// Detects if we're currently within a GetItems call on this thread for this view model. This is used to detect
    /// reentrant calls to GetItems, so we can defer subsequent calls until the first one finishes, to avoid
    /// concurrent GetItems calls which most extensions won't be expecting.
    /// </summary>
    /// <returns>
    /// <see langword="true"/> if we're currently within a GetItems call on this thread for this view model; otherwise, <see langword="false"/>.
    /// </returns>
    private bool IsGetItemsActiveOnCurrentThread()
    {
        var depths = _getItemsDepthByViewModel;
        return depths is not null &&
               depths.TryGetValue(this, out var depth) &&
               depth > 0;
    }

    private void EnterGetItemsScope()
    {
        var depths = _getItemsDepthByViewModel ??= [];
        depths.TryGetValue(this, out var depth);
        depths[this] = depth + 1;
    }

    private void ExitGetItemsScope()
    {
        var depths = _getItemsDepthByViewModel;
        if (depths is null || !depths.TryGetValue(this, out var depth))
        {
            return;
        }

        if (depth == 1)
        {
            depths.Remove(this);
            if (depths.Count == 0)
            {
                _getItemsDepthByViewModel = null;
            }

            try
            {
                QueueDeferredFetchIfNeeded();
            }
            catch (Exception ex)
            {
                CoreLogger.LogError("Failed to queue deferred fetch", ex);
            }
        }
        else
        {
            depths[this] = depth - 1;
        }
    }

    private static void CancelAndDisposeTokenSource(ref CancellationTokenSource? tokenSource)
    {
        var tokenSourceToDispose = Interlocked.Exchange(ref tokenSource, null);
        if (tokenSourceToDispose is null)
        {
            return;
        }

        tokenSourceToDispose.Cancel();
        tokenSourceToDispose.Dispose();
    }

    private void ThrowIfFetchCanceledOrStale(int fetchGeneration, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        if (Volatile.Read(ref _latestFetchGeneration) != fetchGeneration)
        {
            throw new OperationCanceledException();
        }
    }

    private bool IsLatestFetchGeneration(int fetchGeneration)
    {
        return Volatile.Read(ref _latestFetchGeneration) == fetchGeneration;
    }

    private void PublishVmCache(Dictionary<IListItem, ListItemViewModel> newCache)
    {
        Volatile.Write(ref _vmCache, newCache);
    }

    /// <summary>
    /// Helper to generate a weighting for a given list item, based on title,
    /// subtitle, etc. Largely a copy of the version in ListHelpers, but
    /// operating on ViewModels instead of extension objects.
    /// </summary>
    private static int ScoreListItem(string query, CommandItemViewModel listItem)
    {
        if (string.IsNullOrEmpty(query))
        {
            return 1;
        }

        var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
        var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle);
        return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
    }

    private struct ScoredListItemViewModel
    {
        public int Score;
        public ListItemViewModel ViewModel;
    }

    // Similarly stolen from ListHelpers.FilterList
    public static IEnumerable<ListItemViewModel> FilterList(IEnumerable<ListItemViewModel> items, string query)
    {
        var scores = items
            .Where(i => !i.IsInErrorState)
            .Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) })
            .Where(score => score.Score > 0)
            .OrderByDescending(score => score.Score);
        return scores
            .Select(score => score.ViewModel);
    }

    // InvokeItemCommand is what this will be in Xaml due to source generator
    // This is what gets invoked when the user presses <enter>
    [RelayCommand]
    private void InvokeItem(ListItemViewModel? item)
    {
        if (item is not null)
        {
            WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.Command.Model, item.Model));
        }
        else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe is not null)
        {
            WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
                EmptyContent.PrimaryCommand.Command.Model,
                EmptyContent.PrimaryCommand.Model));
        }
    }

    // This is what gets invoked when the user presses <ctrl+enter>
    [RelayCommand]
    private void InvokeSecondaryCommand(ListItemViewModel? item)
    {
        if (item is not null)
        {
            if (item.SecondaryCommand is not null)
            {
                WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(item.SecondaryCommand.Command.Model, item.Model));
            }
        }
        else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe is not null)
        {
            WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(
                EmptyContent.SecondaryCommand.Command.Model,
                EmptyContent.SecondaryCommand.Model));
        }
    }

    [RelayCommand]
    private void UpdateSelectedItem(ListItemViewModel? item)
    {
        if (_lastSelectedItem is not null)
        {
            _lastSelectedItem.PropertyChanged -= SelectedItemPropertyChanged;
        }

        if (item is not null)
        {
            SetSelectedItem(item);
        }
        else
        {
            ClearSelectedItem();
        }
    }

    private void SetSelectedItem(ListItemViewModel item)
    {
        _lastSelectedItem = item;
        _lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;

        WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));

        // Cancel any in-flight slow init from a previous selection and defer
        // the expensive work (extension IPC for MoreCommands, details) so
        // rapid arrow-key navigation skips intermediate items entirely.
        CancelAndDisposeTokenSource(ref _selectedItemCts);
        var cts = _selectedItemCts = new CancellationTokenSource();
        var ct = cts.Token;

        _ = Task.Run(
            () =>
            {
                if (ct.IsCancellationRequested)
                {
                    return;
                }

                if (!item.SafeSlowInit())
                {
                    if (ct.IsCancellationRequested)
                    {
                        return;
                    }

                    WeakReferenceMessenger.Default.Send<HideDetailsMessage>();

                    return;
                }

                if (ct.IsCancellationRequested)
                {
                    return;
                }

                // SafeSlowInit completed on a background thread — details
                // messages will be marshalled to the UI thread by the receiver.
                if (ShowDetails && item.HasDetails)
                {
                    WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
                }
                else
                {
                    WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
                }

                var suggestion = item.TextToSuggest;
                DoOnUiThread(() =>
                {
                    TextToSuggest = suggestion;
                    WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(suggestion));
                });
            },
            ct);
    }

    private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
    {
        var item = _lastSelectedItem;
        if (item is null)
        {
            return;
        }

        // already on the UI thread here
        switch (e.PropertyName)
        {
            case nameof(item.Command):
            case nameof(item.SecondaryCommand):
            case nameof(item.AllCommands):
            case nameof(item.Name):
                WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
                break;
            case nameof(item.Details):
                if (ShowDetails && item.HasDetails)
                {
                    WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
                }
                else
                {
                    WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
                }

                break;
            case nameof(item.TextToSuggest):
                TextToSuggest = item.TextToSuggest;
                break;
        }
    }

    private void ClearSelectedItem()
    {
        CancelAndDisposeTokenSource(ref _selectedItemCts);

        WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
        WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
        WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
        TextToSuggest = string.Empty;
    }

    public override void InitializeProperties()
    {
        base.InitializeProperties();

        var model = _model.Unsafe;
        if (model is null)
        {
            return; // throw?
        }

        _isDynamic = model is IDynamicListPage;

        IsGridView = model.GridProperties is not null;
        UpdateProperty(nameof(IsGridView));

        GridProperties = LoadGridPropertiesViewModel(model.GridProperties);
        GridProperties?.InitializeProperties();
        UpdateProperty(nameof(GridProperties));
        ApplyLayoutToItems();

        ShowDetails = model.ShowDetails;
        UpdateProperty(nameof(ShowDetails));

        _modelPlaceholderText = model.PlaceholderText;
        UpdateProperty(nameof(PlaceholderText));

        InitialSearchText = SearchText = model.SearchText;
        UpdateProperty(nameof(SearchText));
        UpdateProperty(nameof(InitialSearchText));

        EmptyContent = new(new(model.EmptyContent), PageContext, _contextMenuFactory);
        EmptyContent.SlowInitializeProperties();

        Filters?.PropertyChanged -= FiltersPropertyChanged;
        Filters = new(new(model.Filters), PageContext);
        Filters?.PropertyChanged += FiltersPropertyChanged;

        Filters?.InitializeProperties();
        UpdateProperty(nameof(Filters));

        FetchItems(true);
        model.ItemsChanged += Model_ItemsChanged;
    }

    private static IGridPropertiesViewModel? LoadGridPropertiesViewModel(IGridProperties? gridProperties)
    {
        return gridProperties switch
        {
            IMediumGridLayout mediumGridLayout => new MediumGridPropertiesViewModel(mediumGridLayout),
            IGalleryGridLayout galleryGridLayout => new GalleryGridPropertiesViewModel(galleryGridLayout),
            ISmallGridLayout smallGridLayout => new SmallGridPropertiesViewModel(smallGridLayout),
            _ => null,
        };
    }

    public void LoadMoreIfNeeded()
    {
        var model = _model.Unsafe;
        if (model is null)
        {
            return;
        }

        if (!_isLoading.Set())
        {
            return;

            // NOTE: May miss newly available items until next scroll if model
            // state changes between our check and this reset
        }

        _ = Task.Run(() =>
        {
            // Execute all COM calls on background thread to avoid reentrancy issues with UI
            // with the UI thread when COM starts inner message pump
            try
            {
                if (model.HasMoreItems)
                {
                    model.LoadMore();

                    // _isLoading flag will be set as a result of LoadMore,
                    // which must raise ItemsChanged to end the loading.
                }
                else
                {
                    _isLoading.Clear();
                }
            }
            catch (Exception ex)
            {
                _isLoading.Clear();
                ShowException(ex, model.Name);
            }
        });
    }

    protected override void FetchProperty(string propertyName)
    {
        base.FetchProperty(propertyName);

        var model = _model.Unsafe;
        if (model is null)
        {
            return; // throw?
        }

        switch (propertyName)
        {
            case nameof(GridProperties):
                IsGridView = model.GridProperties is not null;
                GridProperties = LoadGridPropertiesViewModel(model.GridProperties);
                GridProperties?.InitializeProperties();
                UpdateProperty(nameof(IsGridView));
                ApplyLayoutToItems();
                break;
            case nameof(ShowDetails):
                ShowDetails = model.ShowDetails;
                break;
            case nameof(PlaceholderText):
                _modelPlaceholderText = model.PlaceholderText;
                break;
            case nameof(SearchText):
                SearchText = model.SearchText;
                break;
            case nameof(EmptyContent):
                EmptyContent = new(new(model.EmptyContent), PageContext, contextMenuFactory: null);
                EmptyContent.SlowInitializeProperties();
                break;
            case nameof(Filters):
                Filters?.PropertyChanged -= FiltersPropertyChanged;
                Filters = new(new(model.Filters), PageContext);
                Filters?.PropertyChanged += FiltersPropertyChanged;
                Filters?.InitializeProperties();
                break;
            case nameof(IsLoading):
                UpdateEmptyContent();
                break;
        }

        UpdateProperty(propertyName);
    }

    private void UpdateEmptyContent()
    {
        UpdateProperty(nameof(ShowEmptyContent));
        if (!ShowEmptyContent || EmptyContent.Model.Unsafe is null)
        {
            return;
        }

        UpdateProperty(nameof(EmptyContent));

        DoOnUiThread(
           () =>
           {
               WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(EmptyContent));
           });
    }

    private void ApplyLayoutToItems()
    {
        lock (_listLock)
        {
            var showsTitle = GridProperties?.ShowTitle ?? true;
            var showsSubtitle = GridProperties?.ShowSubtitle ?? true;

            foreach (var item in Items)
            {
                item.LayoutShowsTitle = showsTitle;
                item.LayoutShowsSubtitle = showsSubtitle;
            }
        }
    }

    public void Dispose()
    {
        GC.SuppressFinalize(this);
        CancelAndDisposeTokenSource(ref _cancellationTokenSource);
        CancelAndDisposeTokenSource(ref filterCancellationTokenSource);
        CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
        CancelAndDisposeTokenSource(ref _selectedItemCts);
    }

    protected override void UnsafeCleanup()
    {
        base.UnsafeCleanup();

        EmptyContent?.SafeCleanup();
        EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary?

        CancelAndDisposeTokenSource(ref _cancellationTokenSource);
        CancelAndDisposeTokenSource(ref filterCancellationTokenSource);
        CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource);
        CancelAndDisposeTokenSource(ref _selectedItemCts);

        lock (_listLock)
        {
            foreach (var item in Items)
            {
                item.SafeCleanup();
            }

            Items.Clear();
            foreach (var item in FilteredItems)
            {
                item.SafeCleanup();
            }

            FilteredItems.Clear();
        }

        PublishVmCache(new(VmCacheComparer));

        Filters?.PropertyChanged -= FiltersPropertyChanged;
        Filters?.SafeCleanup();

        var model = _model.Unsafe;
        if (model is not null)
        {
            model.ItemsChanged -= Model_ItemsChanged;
        }
    }

    private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
    {
        public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);

        public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
    }
}
