using System.Collections.ObjectModel;
using System.Collections.Specialized;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;

namespace DodoSSH.Client.Shell.ViewModels;

/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
/// <param name="SessionId">The terminal, or null when there is none open.</param>
/// <param name="Label">What that terminal is called, for the button.</param>
internal sealed record InsertTarget(uint? SessionId, string Label)
{
    /// <summary>The answer when no tab is open.</summary>
    internal static InsertTarget None { get; } = new(null, string.Empty);

    /// <summary>Whether there is somewhere to insert into.</summary>
    internal bool IsAvailable => SessionId is not null;
}

/// <summary>
/// The saved commands in this keychain, and how to get one into a terminal.
/// </summary>
/// <remarks>
/// <para>
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
/// the vault has any use for.
/// </para>
/// <para>
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para>
/// <para>
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
/// editor asks which vault a <em>new</em> snippet goes into, and the move panel re-seals an existing one into
/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
/// ask for is still the vault's.
/// </para>
/// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject
{
    private readonly VaultViewModel vault;
    private readonly Func<InsertTarget> target;
    private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;

    /// <param name="vault">The open keychain, which owns the list and the writing.</param>
    /// <param name="target">
    /// Which terminal is selected right now. A function rather than a value, because the answer changes every
    /// time the user clicks a tab and this screen is not told about that.
    /// </param>
    /// <param name="insert">
    /// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
    /// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
    /// neither of those is a property of the transport.
    /// </param>
    internal SnippetsViewModel(
        VaultViewModel vault,
        Func<InsertTarget> target,
        Func<uint, string, bool, CancellationToken, Task<bool>> insert)
    {
        this.vault = vault;
        this.target = target;
        this.insert = insert;

        vault.Snippets.CollectionChanged += OnSnippetsChanged;

        // The editor's vault picker is a snapshot of this list — see BuildEditorVaultChoices — and a vault
        // created on the Teams screen with a half-typed snippet open behind it would otherwise not be
        // offered until the editor was closed and opened again.
        vault.TargetVaults.CollectionChanged += OnTargetVaultsChanged;

        Rebuild();
    }

    /// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
    internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];

    [ObservableProperty]
    private string filter = string.Empty;

    [ObservableProperty]
    private SnippetRowViewModel? selected;

    [ObservableProperty]
    private bool isEditing;

    [ObservableProperty]
    private string editorLabel = string.Empty;

    [ObservableProperty]
    private string editorCommand = string.Empty;

    [ObservableProperty]
    private string editorNotes = string.Empty;

    /// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
    /// <remarks>
    /// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
    /// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
    /// not want the same answer and one switch would end up left on by whoever needed it for the first.
    /// </remarks>
    [ObservableProperty]
    private bool editorRunsOnInsert;

    /// <summary>The snippet being edited, or null when the editor would create one.</summary>
    [ObservableProperty]
    private Guid? editingId;

    [ObservableProperty]
    private string status = string.Empty;

    /// <summary>
    /// The vault the open editor will write to.
    /// </summary>
    /// <remarks>
    /// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
    /// existing one — rather than read back off the selection at save time. The list this screen shows spans
    /// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
    /// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
    /// whichever row happened to be highlighted. The same reason <c>VaultViewModel.editingHostVaultId</c>
    /// exists.
    /// </remarks>
    private Guid editorVaultId;

    /// <summary>Which vault the open move panel would send the snippet to.</summary>
    private SnippetRowViewModel? moving;

    /// <summary>
    /// The vaults a new snippet may be filed into.
    /// </summary>
    /// <remarks>
    /// Filled from <c>VaultViewModel.TargetVaults</c>, which is already the readable-and-writable set: a
    /// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
    /// team vault this account is a viewer of. The options are the shared objects rather than copies, so
    /// this picker and the keychain screen's show the same names without either being able to move the
    /// other — what they do not share is the selection.
    /// </remarks>
    internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];

    [ObservableProperty]
    private VaultChoiceViewModel? editorSelectedVault;

    /// <summary>
    /// Whether the editor should be asking which vault this snippet goes into.
    /// </summary>
    /// <remarks>
    /// Only while creating, and only where there is more than one vault to choose between. An existing
    /// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
    /// save, offered by <see cref="Move"/> — and a control offering one option is a question nobody was
    /// asked.
    /// </remarks>
    internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;

    /// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
    /// <remarks>
    /// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
    /// the question is not "are you sure" but "which vault".
    /// </remarks>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
    private bool isMoving;

    /// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
    internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];

    [ObservableProperty]
    private VaultChoiceViewModel? selectedMoveVault;

    /// <summary>
    /// Whether the selected snippet has anywhere to move to.
    /// </summary>
    /// <remarks>
    /// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
    /// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
    /// answer is per snippet: one already in the only other writable vault has nowhere to go.
    /// </remarks>
    internal bool CanMove =>
        Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;

    /// <summary>Whether the buttons under the selected snippet are showing.</summary>
    /// <remarks>
    /// Off while the move panel or the delete confirmation is up, either of which takes their place — the
    /// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
    /// that opened one would otherwise still be there offering to open it again.
    /// </remarks>
    internal bool ShowsSelectionActions => HasSelection && !IsMoving && !IsConfirmingDelete;

    /// <summary>
    /// ── v5b ── Whether the desktop's own delete confirmation card is up.
    /// </summary>
    /// <remarks>See <see cref="RequestDelete"/> for why this exists beside <see cref="DeleteAsync"/> rather than in it.</remarks>
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
    private bool isConfirmingDelete;

    /// <summary>Which snippet the open confirmation card is about.</summary>
    private SnippetRowViewModel? pendingDelete;

    /// <summary>The confirmation card's own question, naming the snippet.</summary>
    internal string DeleteQuestion => pendingDelete is { } row ? $"Delete \"{row.Label}\"?" : string.Empty;

    /// <summary>
    /// The confirmation card's own consequence line — vault-wide reach, a tombstone, no undo — in the same
    /// words <c>VaultViewModel.HowFarADeletionGoes</c> uses for every other kind of item.
    /// </summary>
    internal string DeleteConsequence => pendingDelete is { } row
        ? $"This snippet lives in the {row.VaultName} vault — deleting removes it for everyone who holds "
            + "that key. This writes a tombstone; there is no undo."
        : string.Empty;

    /// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
    /// <remarks>
    /// For the detail pane, which is where somebody decides whether to insert a command into a production
    /// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
    /// is being read.
    /// </remarks>
    internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;

    /// <summary>Whether there is a vault to name beside the selected snippet.</summary>
    internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;

    /// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
    /// <remarks>
    /// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
    /// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
    /// nothing.
    /// </remarks>
    internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));

    internal bool HasVisible => Visible.Count > 0;

    internal bool HasSelection => Selected is not null;

    /// <summary>Whether there is a terminal to insert into at all.</summary>
    internal bool CanInsert => HasSelection && target().IsAvailable;

    /// <summary>
    /// What the insert button says, naming the terminal it will type into.
    /// </summary>
    /// <remarks>
    /// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
    /// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
    /// command, at the moment that is least convenient to be wrong about.
    /// </remarks>
    internal string InsertLabel => target() is { IsAvailable: true } open
        ? $"TYPE INTO {open.Label}"
        : "NO TERMINAL OPEN";

    /// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
    internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;

    /// <summary>Whether the selected snippet is one marked as running on its own.</summary>
    internal bool SelectionRuns => Selected?.RunsOnInsert is true;

    internal string EmptyMessage => HasSnippets
        ? "No snippet matches that."
        : "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
            + "typing it again.";

    /// <summary>Starts a new snippet.</summary>
    /// <remarks>
    /// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
    /// unless that has been changed — because a snippet put in a team's vault is a command everybody in
    /// that team can read, and that has to be chosen rather than defaulted into.
    /// </remarks>
    [RelayCommand]
    private void New()
    {
        CloseMovePanel();

        EditingId = null;
        EditorLabel = string.Empty;
        EditorCommand = string.Empty;
        EditorNotes = string.Empty;
        EditorRunsOnInsert = false;
        BuildEditorVaultChoices(vault.TargetVaultId);
        IsEditing = true;
        Status = "Adding a snippet.";
    }

    /// <summary>Opens the selected snippet for editing.</summary>
    /// <remarks>
    /// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
    /// is not drawn for an existing snippet: its vault is not a field of this form.
    /// </remarks>
    [RelayCommand]
    private void Edit()
    {
        if (Selected is not { } row)
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
            return;
        }

        CloseMovePanel();

        EditingId = row.EntityId;
        EditorLabel = row.Snippet.Label;
        EditorCommand = row.Snippet.Command;
        EditorNotes = row.Snippet.Notes ?? string.Empty;
        EditorRunsOnInsert = row.Snippet.RunsOnInsert;
        BuildEditorVaultChoices(row.VaultId);
        IsEditing = true;
        Status = $"Editing {row.Label}.";
    }

    /// <summary>Abandons the editor.</summary>
    [RelayCommand]
    private void Cancel()
    {
        IsEditing = false;
        EditingId = null;
        Status = string.Empty;
    }

    /// <summary>Stores the editor's contents.</summary>
    [RelayCommand]
    private async Task SaveAsync(CancellationToken cancellationToken)
    {
        var snippet = new SnippetSecret
        {
            Label = EditorLabel.Trim(),

            // Not trimmed, and this is the field where that matters most. A here-document's terminator has
            // to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
            // for one that never comes, which reads as the snippet having hung the terminal.
            Command = EditorCommand,
            Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
            RunsOnInsert = EditorRunsOnInsert,
        };

        var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
            .ConfigureAwait(true);

        if (!saved)
        {
            Status = vault.Status;
            return;
        }

        IsEditing = false;
        EditingId = null;
        Status = vault.Status;
    }

    /// <summary>Deletes the selected snippet.</summary>
    /// <remarks>
    /// Unchanged and still reachable on its own — the phone's DELETE row still calls this directly, full
    /// width and below EDIT and MOVE, with no question in front of it; see the phone's own
    /// <c>SnippetsScreen.axaml</c> remarks for why a counted confirmation was never drawn there. The
    /// desktop's own DELETE goes through <see cref="RequestDelete"/> instead — see that command's remarks —
    /// so this one command now serves two different confirmations rather than the desktop growing a second
    /// deletion path.
    /// </remarks>
    [RelayCommand]
    private async Task DeleteAsync(CancellationToken cancellationToken)
    {
        if (Selected is not { } row)
        {
            return;
        }

        await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);

        Status = vault.Status;
    }

    /// <summary>
    /// ── v5b ── Asks before deleting, the way every other keychain item's DELETE already does.
    /// </summary>
    /// <remarks>
    /// Additive rather than a change to <see cref="DeleteAsync"/> itself: that command is what the phone's
    /// own DELETE row still calls, uncounted, and repurposing it here would leave a phone build with a
    /// button that arms a card nothing on that screen ever draws — a delete that silently stopped working.
    /// The desktop's own confirmation lives here instead, mirroring <c>VaultViewModel.PendingDeletion</c>'s
    /// shape without touching that type: a snippet has no <see cref="DeletionTarget"/> of its own to add
    /// there, and Snippets.dc.html's own modal copy — vault-wide reach, a tombstone, no undo — is exactly
    /// what <c>HowFarADeletionGoes</c> already says for every other kind, said here in the same words.
    /// </remarks>
    [RelayCommand]
    private void RequestDelete()
    {
        if (Selected is not { } row)
        {
            return;
        }

        pendingDelete = row;
        IsConfirmingDelete = true;
    }

    /// <summary>Carries out the deletion the confirm card just agreed to.</summary>
    [RelayCommand]
    private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
    {
        if (pendingDelete is not { } row)
        {
            return;
        }

        IsConfirmingDelete = false;
        pendingDelete = null;

        await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);

        Status = vault.Status;
    }

    /// <summary>Thinks better of it.</summary>
    [RelayCommand]
    private void CancelDelete()
    {
        IsConfirmingDelete = false;
        pendingDelete = null;
    }

    /// <summary>
    /// Opens the panel that asks which vault the selected snippet should move to.
    /// </summary>
    /// <remarks>
    /// <para>
    /// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
    /// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
    /// tombstone in the source — see <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
    /// side effect of saving a corrected typo.
    /// </para>
    /// <para>
    /// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
    /// forms about the same snippet, one of which moves it, is not something anybody should have to read
    /// carefully.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private void Move()
    {
        if (Selected is not { } row || IsEditing)
        {
            return;
        }

        if (row.IsReadOnly)
        {
            Status = "This snippet was written by a newer version of DodoSSH. Moving it would re-encode it "
                + "here and lose what this build cannot read. Update first.";
            return;
        }

        var choices = vault.MoveTargetsBesides(row.VaultId);

        if (choices.Count == 0)
        {
            // The one-vault case, and the honest sentence rather than an empty picker. It is also what
            // somebody in a team whose only other vault is read-only sees.
            Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
            return;
        }

        MoveVaultChoices.Clear();

        foreach (var choice in choices)
        {
            MoveVaultChoices.Add(choice);
        }

        SelectedMoveVault = MoveVaultChoices[0];
        moving = row;
        IsMoving = true;
        Status = string.Empty;
    }

    /// <summary>Abandons the move panel.</summary>
    [RelayCommand]
    private void CancelMove()
    {
        CloseMovePanel();
        Status = string.Empty;
    }

    /// <summary>
    /// Moves the selected snippet into the chosen vault.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>The snippet crosses whole.</b> Nothing on it points at an item of the vault it is leaving — a
    /// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
    /// and nothing to report as left behind.
    /// </para>
    /// <para>
    /// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
    /// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
    /// snippet having been deleted.
    /// </para>
    /// </remarks>
    [RelayCommand]
    private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
    {
        if (moving is not { } row || SelectedMoveVault is not { } target)
        {
            return;
        }

        CloseMovePanel();

        var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);

        // The reload inside the move refilled the list, which rebuilt this one and dropped a selection
        // keyed on an id that no longer exists.
        if (moved is { } entityId)
        {
            Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
        }

        Status = vault.Status;
    }

    /// <summary>
    /// Types the selected snippet into the selected terminal, without pressing Enter.
    /// </summary>
    /// <remarks>
    /// The button that does not run anything, and it is the one a user should reach for. What it inserts
    /// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
    /// at the prompt as text and waits for a person to look at it.
    /// </remarks>
    [RelayCommand]
    private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);

    /// <summary>
    /// Types the selected snippet into the selected terminal and presses Enter.
    /// </summary>
    /// <remarks>
    /// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
    /// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
    /// every one of them.
    /// </remarks>
    [RelayCommand]
    private Task RunAsync(CancellationToken cancellationToken) =>
        SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;

    internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;

    /// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
    /// <remarks>
    /// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
    /// this screen — a session survives locking the keychain — so a subscription in this direction would be
    /// a screen holding the shell alive.
    /// </remarks>
    internal void TargetChanged()
    {
        OnPropertyChanged(nameof(CanInsert));
        OnPropertyChanged(nameof(InsertLabel));
        OnPropertyChanged(nameof(RunLabel));
    }

    private async Task SendAsync(bool execute, CancellationToken cancellationToken)
    {
        if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
        {
            Status = "Open a terminal first — a snippet has to go somewhere.";
            return;
        }

        var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
            .ConfigureAwait(true);

        Status = delivered
            ? execute
                ? $"Ran '{row.Label}' in {open.Label}."
                : $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
            : $"{open.Label} is no longer connected, so nothing was sent.";
    }

    partial void OnFilterChanged(string value) => Rebuild();

    /// <remarks>
    /// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
    /// matching the snippet would leave a picker on screen aimed at a row nobody can see.
    /// </remarks>
    partial void OnSelectedChanged(SnippetRowViewModel? value)
    {
        if (IsMoving && value?.EntityId != moving?.EntityId)
        {
            CloseMovePanel();
        }

        // The confirmation card folds away with the selection it was opened about, the same rule the move
        // panel above follows and for the same reason: a filter or a reload that moved the selection
        // elsewhere would otherwise leave a "delete this?" card pointed at a row nobody is looking at.
        if (IsConfirmingDelete && value?.EntityId != pendingDelete?.EntityId)
        {
            IsConfirmingDelete = false;
            pendingDelete = null;
        }

        OnPropertyChanged(nameof(HasSelection));
        OnPropertyChanged(nameof(CanInsert));
        OnPropertyChanged(nameof(SelectionRuns));
        OnPropertyChanged(nameof(CanMove));
        OnPropertyChanged(nameof(ShowsSelectionActions));
        OnPropertyChanged(nameof(SelectionVaultBadge));
        OnPropertyChanged(nameof(HasSelectionVaultBadge));
    }

    partial void OnEditingIdChanged(Guid? value)
    {
        OnPropertyChanged(nameof(IsCreating));
        OnPropertyChanged(nameof(ShowsEditorVaultChoice));
    }

    /// <summary>
    /// Moves a half-typed snippet into the vault just chosen for it.
    /// </summary>
    /// <remarks>
    /// Only while creating, and this guard is what makes that true rather than the view merely not drawing
    /// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — but not this
    /// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
    /// vault and leave the original behind, which is a fork rather than a move.
    /// </remarks>
    partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
    {
        if (value is null || EditingId is not null)
        {
            return;
        }

        editorVaultId = value.VaultId;
    }

    /// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
    private void BuildEditorVaultChoices(Guid vaultId)
    {
        editorVaultId = vaultId;

        EditorVaultChoices.Clear();

        foreach (var choice in vault.TargetVaults)
        {
            EditorVaultChoices.Add(choice);
        }

        // Null where the snippet's vault is one this session cannot write — a team vault this account is a
        // viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
        // answer than an option that would move the snippet if it were touched.
        EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);

        OnPropertyChanged(nameof(ShowsEditorVaultChoice));
    }

    private void CloseMovePanel()
    {
        IsMoving = false;
        moving = null;
        MoveVaultChoices.Clear();
        SelectedMoveVault = null;
    }

    /// <summary>Whether the editor would create a snippet rather than replace one.</summary>
    internal bool IsCreating => EditingId is null;

    private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();

    /// <summary>Refills the open editor's vault picker, landing back on the vault it was already writing to.</summary>
    /// <remarks>
    /// Only while the editor is open, because that is the only picker built from a copy of the list — the move
    /// panel's is built when it opens and folds away with the selection it was opened about. Restored by the
    /// latched <see cref="editorVaultId"/> rather than by what the control shows, so a vault arriving mid-edit
    /// cannot move a half-typed snippet: the refill is invisible except for the entry it adds.
    /// </remarks>
    private void OnTargetVaultsChanged(object? sender, NotifyCollectionChangedEventArgs e)
    {
        if (IsEditing)
        {
            BuildEditorVaultChoices(editorVaultId);
        }
    }

    private void Rebuild()
    {
        // Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
        // way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
        // writes that null straight back before the refill can matter.
        var selectedId = Selected?.EntityId;

        Visible.Clear();

        foreach (var snippet in vault.Snippets.Where(Matches))
        {
            Visible.Add(snippet);
        }

        Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);

        OnPropertyChanged(nameof(HasSnippets));
        OnPropertyChanged(nameof(HasVisible));
        OnPropertyChanged(nameof(EmptyMessage));
        OnPropertyChanged(nameof(CanMove));
    }

    /// <remarks>
    /// <para>
    /// The command is searched as well as the name and the notes, because half of what somebody remembers
    /// about a saved command is a word that was in it.
    /// </para>
    /// <para>
    /// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, which is
    /// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
    /// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
    /// </para>
    /// </remarks>
    private bool Matches(SnippetRowViewModel row)
    {
        if (!vault.IsVaultShown(row.VaultId))
        {
            return false;
        }

        var needle = Filter.Trim();

        if (needle.Length == 0)
        {
            return true;
        }

        return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);

        bool Contains(string? value) =>
            value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
    }
}
