using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;

namespace DodoSSH.Client.App.Layout.Tests;

/// <summary>
/// How the grid of host cards answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
/// <c>HostsScreen.axaml.cs</c>.
/// </para>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
/// quieter still.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostGridTests : IAsyncLifetime
{
    private const string Passphrase = "a sufficiently long passphrase";
    private const string ServerUrl = "https://dodossh.example";

    /// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
    private static readonly Argon2Profile CheapProfile =
        Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);

    private readonly FakeAccountServer server = new();
    private readonly StubKeyBinding keyBinding = new();
    private readonly VaultKnownHostStore knownHosts = new();

    private ClientCacheFactory caches = null!;
    private TerminalWorkspace workspace = null!;
    private VaultSession session = null!;
    private VaultViewModel vault = null!;

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <inheritdoc />
    public async ValueTask InitializeAsync()
    {
        caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
        await caches.MigrateAsync(Token);

        await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
            .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);

        var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
        outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
        session = outcome.Session!;

        workspace = new TerminalWorkspace(
            new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
            Substitute.For<ISshConnectionFactory>(),
            TimeProvider.System);

        await knownHosts.OpenAsync(session, Token);

        vault = new VaultViewModel(session, workspace, knownHosts, static () => null);

        await SeedAsync();
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        knownHosts.Close();
        await workspace.DisposeAsync();
        await vault.DisposeAsync();
        caches.Dispose();
    }

    /// <remarks>
    /// The rule the menu depends on. Without it the three commands would act on whatever was selected
    /// before, which for Delete is a question asked about one machine and answered about another.
    /// </remarks>
    [Fact]
    public async Task ARightClickSelectsTheHostUnderThePointer()
    {
        await OnTheGridAsync((screen, window) =>
        {
            var first = Row(vault, "prod-db");
            var other = Row(vault, "stage-web");

            vault.SelectedHost = first;

            RightClick(CardFor(screen, other), window);

            vault.SelectedHost.ShouldBeSameAs(other);

            var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
            menu.IsOpen.ShouldBeTrue();

            // The commands are the vault's, which is the other half of putting the menu on the list rather
            // than in the item template: a menu inside the template would have the row for its data context,
            // and every one of these would silently bind to nothing.
            //
            // Drawn rather than merely present, because this menu carries a second Edit… that is about the
            // ticked set rather than about the selection; with nothing ticked that one is collapsed. See
            // TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
            var edit = menu.Items.OfType<MenuItem>()
                .Single(item => item.IsVisible && item.Header is "Edit…");
            edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);

            edit.Command!.Execute(null);

            vault.IsEditing.ShouldBeTrue();
            vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
        });
    }

    /// <remarks>
    /// <para>
    /// The space around the cards is part of the same <c>ListBox</c>, and a menu offering Connect, Edit and
    /// Delete over it would be three entries acting on whichever machine happened to be selected — which is
    /// the whole mistake this handler exists to prevent, reached by clicking nothing at all.
    /// </para>
    /// <para>
    /// Raised on the list itself rather than clicked at a point known to be empty. What the handler reads is
    /// the event's source, and a source that is the list rather than an item is exactly what the empty space
    /// produces; a coordinate would additionally be asserting where the wrap put the cards.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
    {
        await OnTheGridAsync((screen, _) =>
        {
            var selected = Row(vault, "prod-db");
            vault.SelectedHost = selected;

            screen.HostGrid.RaiseEvent(new ContextRequestedEventArgs
            {
                RoutedEvent = Control.ContextRequestedEvent,
                Source = screen.HostGrid,
            });

            vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
            screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
        });
    }

    /// <summary>
    /// The same rule on the cards above, where getting it wrong is quieter and worse.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
    /// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
    /// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
    /// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
    /// </para>
    /// <para>
    /// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
    /// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
    /// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ARightClickSelectsTheGroupUnderThePointer()
    {
        await AddGroupAsync("staging");

        await OnTheGridAsync((screen, window) =>
        {
            var first = GroupRow(vault, "production");
            var other = GroupRow(vault, "staging");

            vault.SelectedGroup = first;

            RightClick(CardFor(screen, other), window);

            vault.SelectedGroup.ShouldBeSameAs(other);

            var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
            menu.IsOpen.ShouldBeTrue();

            var items = menu.Items.OfType<MenuItem>().ToList();

            var open = items.Single(item => item.Header is "Open");
            open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
            open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");

            var edit = items.Single(item => item.Header is "Edit…");
            edit.Command.ShouldBeSameAs(vault.EditGroupCommand);

            edit.Command!.Execute(null);

            vault.IsEditingGroup.ShouldBeTrue();
            vault.GroupEditorLabel.ShouldBe(
                other.Label, "the card that was right-clicked, not the one selected before");
        });
    }

    /// <remarks>
    /// The space around the group cards, where a menu would be at its most misleading: nothing is under the
    /// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
    /// trail ends with — which, once it is open, is not a card on screen at all.
    /// </remarks>
    [Fact]
    public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
    {
        await OnTheGridAsync((screen, _) =>
        {
            var selected = GroupRow(vault, "production");
            vault.SelectedGroup = selected;

            screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
            {
                RoutedEvent = Control.ContextRequestedEvent,
                Source = screen.GroupGrid,
            });

            vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
            screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
        });
    }

    /// <summary>
    /// A host held over a group card would be filed there, and one held over another host card would not.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The group headings that used to sit between the cards are gone — see
    /// <c>ScreenLayoutTests.TheHostsGridHoldsCardsAndNoGroupHeadings</c> — and with them went the thing a
    /// dragged host was dropped onto. This holds the replacement in place, and the refusal with it: a card
    /// dropped onto another card used to file it beside that one, which was legible while a heading said
    /// which group the band of cards belonged to and is guesswork now that none does.
    /// </para>
    /// <para>
    /// What is <em>not</em> here is the platform's half — picking the card up, the cursor, the drop itself.
    /// Headless Avalonia has no native window and can synthesise none of it. The write at the end of the
    /// gesture is <c>ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected</c>, and what neither
    /// covers is in docs/manual-checks.md 7.6.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheGroupCardsAreWhatAcceptsADroppedHost()
    {
        await OnTheGridAsync((screen, _) =>
        {
            var carried = new DataTransfer();
            carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));

            var onto = screen.GroupGrid
                .GetVisualDescendants()
                .OfType<ListBoxItem>()
                .Single(item => item.DataContext is HostGroupRowViewModel);

            var over = Over(onto, carried);

            onto.Classes.ShouldContain("droptarget", "the card says it would take the host");
            over.DragEffects.ShouldBe(DragDropEffects.Move);

            var refused = Over(CardFor(screen, Row(vault, "stage-web")), carried);

            refused.Handled.ShouldBeTrue("the screen answered rather than leaving it to the platform");
            refused.DragEffects.ShouldBe(
                DragDropEffects.None,
                "a card dropped onto another card would be filed somewhere nothing on screen names");

            // And the group card it was over a moment ago stops offering to take it, which is the half of
            // this that is wrong far more often than the mark appearing at all.
            onto.Classes.ShouldNotContain("droptarget");
        });
    }

    /// <remarks>
    /// <para>
    /// The rule one press was split into two gestures for. Selecting a group marks it and does nothing else;
    /// opening one is what narrows the grid, and the trail is the way back out of it. While a single press
    /// meant both, a group could not be named without every host outside it leaving the screen at the same
    /// moment.
    /// </para>
    /// <para>
    /// Driven through the properties the cards bind rather than through a click, because what is worth
    /// holding here is the rule; the pointer is put on the gesture itself in the test below. A click would
    /// otherwise be testing Avalonia's <c>SelectedItem</c> binding, which is not this application's code.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
    {
        await vault.MoveHostToGroupCommand.ExecuteAsync(
            new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));

        // Re-found after the move, because the reload it ends with replaces every row in the list.
        var production = vault.VisibleGroups.Single();

        vault.SelectedGroup = production;

        vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
        vault.VisibleHosts.Select(row => row.Label)
            .ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
        vault.GroupTarget.ShouldBeSameAs(production, "what a group command with no argument acts on");

        vault.OpenGroupCommand.Execute(production);

        vault.VisibleHosts.Select(row => row.Label)
            .ShouldBe(["prod-db"], "only what is filed under the group that is open");

        vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);

        vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
        vault.GroupTarget.ShouldBeSameAs(
            production, "so an unaimed command falls back to the group whose contents are showing");

        // Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
        // same job and went with the change.
        vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group);

        vault.VisibleHosts.Select(row => row.Label)
            .ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
        vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
    }

    /// <summary>
    /// The two grids share one selection, so at most one card on the screen is ever lit.
    /// </summary>
    /// <remarks>
    /// <para>
    /// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
    /// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
    /// which only one acts on whichever card the eye has settled on. The vault is what joins them.
    /// </para>
    /// <para>
    /// Driven on the screen rather than on the view model alone, because half of the rule lives in the
    /// controls: clearing the property has to reach the list that is drawing the card, and a selection
    /// nulled in the view model while the card stays highlighted is the exact failure this is about.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheHostAndGroupGridsShareOneSelection()
    {
        await OnTheGridAsync((screen, _) =>
        {
            var host = Row(vault, "stage-web");

            vault.OpenHostPaneCommand.Execute(host);
            Dispatcher.UIThread.RunJobs();

            vault.SelectedGroup = vault.VisibleGroups.Single();
            Dispatcher.UIThread.RunJobs();

            vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
            vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
            screen.HostGrid.SelectedItem.ShouldBeNull();
            CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
            vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");

            vault.SelectedHost = host;
            Dispatcher.UIThread.RunJobs();

            vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
            screen.GroupGrid.SelectedItem.ShouldBeNull();
            GroupCard(screen).IsSelected.ShouldBeFalse();
        });
    }

    /// <remarks>
    /// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
    /// the whole of both now — the menu came second and did the same job better, since it acts on the card
    /// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
    /// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
    /// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
    /// </remarks>
    [Fact]
    public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
    {
        await OnTheGridAsync((screen, _) =>
        {
            vault.SelectedGroup = vault.VisibleGroups.Single();
            Dispatcher.UIThread.RunJobs();

            screen.GetVisualDescendants()
                .OfType<Button>()
                .Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
                    || ReferenceEquals(button.Command, vault.DeleteGroupCommand))
                .ShouldBeEmpty("a selected group card puts no buttons on the screen");
        });
    }

    /// <remarks>
    /// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
    /// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
    /// between the two grids that fallback would quietly unselect a group card every time a sync landed.
    /// </remarks>
    [Fact]
    public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
    {
        var production = vault.VisibleGroups.Single();

        vault.SelectedGroup = production;
        vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");

        await vault.LoadAsync(Token);

        vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
        vault.SelectedGroup
            .ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
            .EntityId.ShouldBe(production.EntityId);
    }

    /// <remarks>
    /// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
    /// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
    /// what it asserts is the half that would go unnoticed: one press must still only select, or the split
    /// bought nothing.
    /// </remarks>
    [Fact]
    public async Task DoubleClickingAGroupCardOpensIt()
    {
        // One press, in a window of its own, because a second pair of clicks in the same one is the gesture
        // this is separating that press from.
        await OnTheGridAsync((screen, window) =>
        {
            var centre = Centre(GroupCard(screen), window);

            window.MouseDown(centre, MouseButton.Left);
            window.MouseUp(centre, MouseButton.Left);

            Dispatcher.UIThread.RunJobs();

            vault.SelectedGroup.ShouldNotBeNull("one press selects the card");
            vault.GroupFilter.ShouldBeNull("and opens nothing");
        });

        await OnTheGridAsync((screen, window) =>
        {
            var centre = Centre(GroupCard(screen), window);

            window.MouseDown(centre, MouseButton.Left);
            window.MouseUp(centre, MouseButton.Left);
            window.MouseDown(centre, MouseButton.Left);
            window.MouseUp(centre, MouseButton.Left);

            Dispatcher.UIThread.RunJobs();

            vault.GroupFilter.ShouldNotBeNull().Label.ShouldBe("production");
            vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "production"]);

            // And out again through the trail as it is actually drawn, rather than through the command. A
            // crumb is an item in a template and the command it presses is the vault's, so the two are
            // joined by a $parent binding — which is a string that compiles whether or not it resolves, and
            // would leave a trail of buttons that do nothing.
            var back = screen.GetVisualDescendants()
                .OfType<Button>()
                .First(button => button.DataContext is GroupCrumbViewModel { Group: null });

            back.Command.ShouldNotBeNull("the crumb reached the vault's command").Execute(back.CommandParameter);

            vault.GroupFilter.ShouldBeNull("ALL HOSTS is the way back out");
            vault.VisibleHosts.Count.ShouldBe(2);
        });
    }

    /// <summary>
    /// The cards are one level of the tree, and the trail is how that level was reached.
    /// </summary>
    /// <remarks>
    /// The grid used to draw every group at once, which was the only honest thing to do while a card was a
    /// filter: a filter nobody can see is a filter nobody can turn off. Once opening a group became
    /// navigation the cards became its contents — and a level with no name and no way back is a grid that
    /// has quietly hidden things, which is what the trail is for.
    /// </remarks>
    [Fact]
    public async Task OpeningANestedGroupShowsWhatIsInsideItAndTheWayBack()
    {
        await AddGroupAsync("estate");
        await FileGroupUnderAsync("production", "estate");

        vault.VisibleGroups.Select(row => row.Label)
            .ShouldBe(["estate"], "the outermost groups, and production is not one of them any more");

        vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());

        vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"], "what is inside estate");
        vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);

        vault.OpenGroupCommand.Execute(vault.VisibleGroups.Single());

        vault.HasVisibleGroups.ShouldBeFalse("production has nothing inside it, so the cards fold away");
        vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate", "production"]);

        // A middle crumb goes back one level rather than all the way out, which is the whole reason the
        // trail is a row of buttons instead of a sentence saying where you are.
        vault.OpenGroupCommand.Execute(vault.GroupTrail[1].Group);

        vault.VisibleGroups.Select(row => row.Label).ShouldBe(["production"]);
        vault.GroupTrail.Select(crumb => crumb.Name).ShouldBe(["ALL HOSTS", "estate"]);
    }

    /// <summary>
    /// Choosing a host costs nothing, and the pencil on its card is what spends the 304 pixels.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The two halves are one rule and are asserted together, because either alone would pass on a broken
    /// version: a drawer that never opens satisfies the first, and one that opens on selection satisfies the
    /// second. What is being held is that opening is <em>deliberate</em>.
    /// </para>
    /// <para>
    /// Driven through the card's own button rather than by executing the command, since the thing most
    /// likely to rot is the binding that reaches out of the item template to the vault's command — a
    /// <c>$parent[ListBox]</c> path that resolves to nothing compiles, draws, and does nothing when pressed.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
    {
        await OnTheGridAsync((screen, _) =>
        {
            var host = Row(vault, "stage-web");

            vault.SelectedHost = host;

            vault.IsDrawerOpen.ShouldBeFalse("selecting a card is not asking for the pane");

            // The button is hidden until the pointer is on the card, so a click cannot be synthesised at a
            // point: what a headless run can reach is the control and the command behind it.
            var pencil = CardFor(screen, host)
                .GetVisualDescendants()
                .OfType<Button>()
                .First(button => button.Classes.Contains("rowedit"));

            pencil.Command.ShouldNotBeNull("the template's binding to the vault's command has to resolve");
            pencil.Command.Execute(pencil.CommandParameter);

            vault.IsDrawerOpen.ShouldBeTrue();
            vault.IsShowingHostDetail.ShouldBeTrue("the pane, not one of the two editors");
            vault.SelectedHost.ShouldBeSameAs(host, "the card the pencil was on");
        });
    }

    /// <remarks>
    /// The pane follows the selection once it is open — see <c>VaultViewModel.IsHostPaneOpen</c> — but a
    /// selection that goes away entirely has to take it with it. Without that the flag would survive a
    /// filter matching nothing, and the drawer would spring open again on the next card merely selected,
    /// which is the behaviour the pencil exists to remove.
    /// </remarks>
    [Fact]
    public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
    {
        await OnTheGridAsync((_, _) =>
        {
            vault.OpenHostPaneCommand.Execute(Row(vault, "prod-db"));
            vault.IsDrawerOpen.ShouldBeTrue();

            vault.SelectedHost = null;
            vault.IsDrawerOpen.ShouldBeFalse();

            vault.SelectedHost = Row(vault, "stage-web");
            vault.IsDrawerOpen.ShouldBeFalse("the pane has to be asked for again");
        });
    }

    // ---- ◆ Choosing more than one card ----
    //
    // The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
    // with a pointer instead of a long press. What these hold is the half that belongs to this head: which
    // gesture means what, and the rule that keeps the list's own selection and the set from ever both being
    // about something at the same moment. See HostsScreen.axaml.cs.

    /// <remarks>
    /// <para>
    /// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
    /// also moved the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
    /// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
    /// down rather than acted on as it bubbles.
    /// </para>
    /// <para>
    /// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
    /// card is in play and every command on this screen is about the same host.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
    {
        await OnTheGridAsync((screen, window) =>
        {
            var first = Row(vault, "prod-db");
            var second = Row(vault, "stage-web");

            vault.SelectedHost = first;
            Dispatcher.UIThread.RunJobs();

            Click(CardFor(screen, second), window, RawInputModifiers.Control);

            vault.ChosenHostCount.ShouldBe(1);
            second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
            vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
            screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");

            Click(CardFor(screen, first), window, RawInputModifiers.Control);
            vault.ChosenHostCount.ShouldBe(2);

            Click(CardFor(screen, first), window, RawInputModifiers.Control);
            vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");

            Click(CardFor(screen, first), window);

            vault.IsChoosingHosts.ShouldBeFalse("a plain click drops the set");
            vault.SelectedHost.ShouldBeSameAs(first, "and selects the card it landed on, as it always has");
        });
    }

    /// <remarks>
    /// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
    /// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
    /// the grid's own, so "between" means between as the cards are laid out.
    /// </remarks>
    [Fact]
    public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
    {
        await AddHostAsync("dev-box");

        await OnTheGridAsync((screen, window) =>
        {
            var order = vault.VisibleHosts.ToList();

            order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");

            Click(CardFor(screen, order[0]), window);
            Click(CardFor(screen, order[2]), window, RawInputModifiers.Shift);

            vault.ChosenHostCount.ShouldBe(3);
            order.ShouldAllBe(row => row.IsChosen);

            Click(CardFor(screen, order[1]), window, RawInputModifiers.Shift);

            vault.ChosenHostCount.ShouldBe(2);
            order[2].IsChosen.ShouldBeFalse("the run is re-measured from the anchor, not extended");
        });
    }

    /// <summary>
    /// The band, dragged out over the space around the cards.
    /// </summary>
    /// <remarks>
    /// <para>
    /// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
    /// selection or the start of a drag of that host, and the band is what the space between and below them
    /// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
    /// and a band that had to contain one would tick nothing at all when drawn down a column.
    /// </para>
    /// <para>
    /// The second half is the same press without the drag: a click on the empty space is how somebody who
    /// never finds Esc gets out of a selection.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
    {
        await OnTheGridAsync((screen, window) =>
        {
            var first = CardFor(screen, Row(vault, "prod-db"));
            var last = CardFor(screen, Row(vault, "stage-web"));

            var topLeft = Corner(first, window);
            var bottomRight = Corner(last, window)
                + new Point(last.Bounds.Width, last.Bounds.Height);

            // Below every card, so the press lands on the scroller rather than on a list item.
            var from = bottomRight.WithY(bottomRight.Y + 24);
            var to = topLeft + new Point(2, 2);

            // The button has to be named on the moves as well as on the press: a headless move carries the
            // button state in its modifiers, and one sent without it is the pointer being let go of.
            window.MouseDown(from, MouseButton.Left);
            window.MouseMove(new Point(to.X, from.Y), RawInputModifiers.LeftMouseButton);
            window.MouseMove(to, RawInputModifiers.LeftMouseButton);

            Dispatcher.UIThread.RunJobs();

            vault.ChosenHostCount.ShouldBe(2, "the band was over both cards");

            window.MouseUp(to, MouseButton.Left);

            vault.ChosenHostCount.ShouldBe(2, "and letting go keeps what it was over");

            window.MouseDown(from, MouseButton.Left);
            window.MouseUp(from, MouseButton.Left);

            Dispatcher.UIThread.RunJobs();

            vault.IsChoosingHosts.ShouldBeFalse("a press and a release with no band between them is a click");
        });
    }

    /// <summary>
    /// One menu with two halves, and which half is drawn is whether anything is ticked.
    /// </summary>
    /// <remarks>
    /// This is the multi-card version of the mistake the first test in this file exists for. The entries that
    /// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
    /// must never happen is both being offered at once: a Delete… asking about the card under the pointer
    /// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
    /// direction. A right click outside the set is what drops it, so the two are never both meaningful.
    /// </remarks>
    [Fact]
    public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
    {
        await OnTheGridAsync((screen, window) =>
        {
            var ticked = Row(vault, "prod-db");
            var other = Row(vault, "stage-web");

            Click(CardFor(screen, ticked), window, RawInputModifiers.Control);

            RightClick(CardFor(screen, ticked), window);

            var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();

            menu.IsOpen.ShouldBeTrue();
            vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");

            var offered = menu.Items.OfType<MenuItem>().Where(item => item.IsVisible).ToList();

            offered.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteChosenHostsCommand));
            offered.ShouldNotContain(
                item => ReferenceEquals(item.Command, vault.DeleteHostCommand),
                "the entries about the selection are not offered beside the entries about the set");

            menu.Close();

            RightClick(CardFor(screen, other), window);

            vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
            vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");

            menu.Items.OfType<MenuItem>()
                .Where(item => item.IsVisible)
                .ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
        });
    }

    /// <remarks>
    /// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
    /// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
    /// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
    /// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
    /// </remarks>
    [Fact]
    public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
    {
        await AddHostAsync("dev-box");

        vault.HostFilter = "prod";

        await OnTheGridAsync((screen, window) =>
        {
            vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");

            screen.HostGrid.Focus();
            Dispatcher.UIThread.RunJobs();

            screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");

            window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);

            vault.ChosenHostCount.ShouldBe(1, "the one card being drawn, not the three hosts there are");

            window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);

            vault.IsChoosingHosts.ShouldBeFalse();
        });
    }

    /// <remarks>
    /// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
    /// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
    /// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
    /// would be a drop the user can see the point of and cannot make.
    /// </remarks>
    [Fact]
    public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
    {
        await OnTheGridAsync((screen, _) =>
        {
            IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];

            var carried = new DataTransfer();
            carried.Add(DataTransferItem.Create(HostFormat, set));

            var onto = screen.GroupGrid
                .GetVisualDescendants()
                .OfType<ListBoxItem>()
                .Single(item => item.DataContext is HostGroupRowViewModel);

            var over = Over(onto, carried);

            over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
        });
    }

    // ---- Helpers ----

    /// <summary>The same in-process format the screen's own drag carries.</summary>
    /// <remarks>
    /// Declared again here rather than made visible, because what the two have in common is the contract —
    /// the name and the type — and a test holding the screen's own field would go on passing if the screen
    /// started carrying something else under it.
    /// </remarks>
    private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
        DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");

    /// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
    /// <remarks>
    /// The nearest a headless test gets to the gesture. No platform drag can be synthesised — there is no
    /// native window to start one — but <c>DragOver</c> is an ordinary routed event, and it is where every
    /// decision this screen makes about a drop is taken: whether the thing under the pointer would accept
    /// the host, and whether it is marked while it is being held there. The drop itself only repeats that
    /// question and runs the command. See docs/manual-checks.md 7.6 for what is left over.
    /// </remarks>
    private static DragEventArgs Over(Interactive target, DataTransfer carried)
    {
        var over = new DragEventArgs(DragDrop.DragOverEvent, carried, target, default, KeyModifiers.None);

        target.RaiseEvent(over);

        return over;
    }

    private static void RightClick(Visual row, Visual window)
    {
        var at = Centre(row, window);

        ((Window)window).MouseDown(at, MouseButton.Right);
        ((Window)window).MouseUp(at, MouseButton.Right);
    }

    /// <summary>A press and a release on one card, with whatever was being held down at the time.</summary>
    /// <remarks>
    /// Both halves, because the two say different things here: the press is where a modifier is read and
    /// where the set is dropped, and the release is where a press on a ticked card that turned out not to be
    /// a drag collapses onto it.
    /// </remarks>
    private static void Click(Visual card, Window window, RawInputModifiers held = RawInputModifiers.None)
    {
        var at = Centre(card, window);

        window.MouseDown(at, MouseButton.Left, held);
        window.MouseUp(at, MouseButton.Left, held);

        Dispatcher.UIThread.RunJobs();
    }

    private static Point Corner(Visual control, Visual window) =>
        control.TranslatePoint(default, window)
        ?? throw new InvalidOperationException("the control is not in this window's tree");

    private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new HostsScreen { DataContext = vault };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);

                try
                {
                    body(screen, window);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    private static ListBoxItem GroupCard(HostsScreen screen) =>
        screen.GroupGrid
            .GetVisualDescendants()
            .OfType<ListBoxItem>()
            .Single(item => item.DataContext is HostGroupRowViewModel);

    /// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
    private static ListBoxItem CardFor(Visual screen, object row) =>
        screen.GetVisualDescendants()
            .OfType<ListBoxItem>()
            .First(item => ReferenceEquals(item.DataContext, row));

    private static HostRowViewModel Row(VaultViewModel vault, string label) =>
        vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));

    /// <remarks>
    /// Out of the cards on screen rather than out of every group, because that is what the card's own data
    /// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
    /// </remarks>
    private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
        vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));

    private static Point Centre(Visual control, Visual window) =>
        control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
        ?? throw new InvalidOperationException("the control is not in this window's tree");

    private async Task AddGroupAsync(string label)
    {
        vault.GroupEditorLabel = label;

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    /// <summary>Files one group under another the way a user can: through the group's own editor.</summary>
    private async Task FileGroupUnderAsync(string group, string parent)
    {
        vault.SelectedGroup = vault.Groups.Single(
            row => string.Equals(row.Label, group, StringComparison.Ordinal));

        vault.EditGroupCommand.Execute(null);

        vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
            choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    /// <summary>One more machine, the way somebody adds one: through the editor.</summary>
    private async Task AddHostAsync(string label)
    {
        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = label;
        vault.EditorHostname = $"{label}.internal";
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);
        await vault.LoadAsync(Token);
    }

    /// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
    private async Task SeedAsync()
    {
        foreach (var label in new[] { "prod-db", "stage-web" })
        {
            vault.NewHostCommand.Execute(null);
            vault.EditorLabel = label;
            vault.EditorHostname = $"{label}.internal";
            vault.EditorUsername = "deploy";

            await vault.SaveHostCommand.ExecuteAsync(null);
        }

        vault.GroupEditorLabel = "production";
        await vault.SaveGroupCommand.ExecuteAsync(null);

        await vault.LoadAsync(Token);
    }
}
