using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using DodoSSH.Client.Shell.ViewModels;

namespace DodoSSH.Client.App.Views;

/// <summary>
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
/// </summary>
/// <remarks>
/// <para>
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
/// </para>
/// <para>
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
/// different.
/// </para>
/// <para>
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
/// beside the grid inherits the same one.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
    /// <summary>
    /// How hosts travel from the cards they were picked up on to the group card they are dropped on.
    /// </summary>
    /// <remarks>
    /// <para>
    /// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
    /// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
    /// drop needs: they know which vault each edit has to return to, which an id on its own does not.
    /// </para>
    /// <para>
    /// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
    /// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
    /// a gesture that quietly does a fraction of what it looks like it does.
    /// </para>
    /// </remarks>
    private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
        DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");

    /// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
    /// <remarks>
    /// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
    /// half of the double-click that connects. Starting a drag on the press itself would turn every one of
    /// those into a drag gesture the user never asked for. The band on the empty space between the cards
    /// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
    /// flashed up on every one of those would be a gesture reported where none happened.
    /// </remarks>
    private const double DragThreshold = 5;

    /// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
    /// <remarks>
    /// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
    /// carrying something the user is looking at — a band the width of a hairline would only be found by
    /// accident, and only by somebody who did not need it.
    /// </remarks>
    private const double EdgeBand = 48;

    /// <summary>How far one drag event inside that band moves the grid.</summary>
    /// <remarks>
    /// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
    /// is moving. A step of a whole card would jump the target out from under the pointer between two events.
    /// </remarks>
    private const double EdgeStep = 24;

    /// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
    /// <remarks>
    /// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
    /// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
    /// over to the operating system.
    /// </remarks>
    private PointerPressedEventArgs? press;

    /// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
    private IReadOnlyList<HostRowViewModel> pickedUp = [];

    private Point origin;

    /// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
    private ListBoxItem? marked;

    /// <summary>
    /// Where a Shift-click measures its run from: the last card pressed without one.
    /// </summary>
    /// <remarks>
    /// Kept here rather than in the vault because it is a fact about the gesture rather than about the
    /// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
    /// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
    /// to the selected card for.
    /// </remarks>
    private HostRowViewModel? anchor;

    /// <summary>
    /// The ticked card a plain press landed on, which the release collapses the set onto.
    /// </summary>
    /// <remarks>
    /// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
    /// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
    /// was an ordinary click and means what a click always means here — this one card, and nothing else. If
    /// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
    /// </remarks>
    private HostRowViewModel? collapse;

    /// <summary>Whether a band is being dragged out over the grid right now.</summary>
    private bool banding;

    /// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
    private Point bandFrom;

    /// <summary>Whether that band adds to the set rather than being the whole of it.</summary>
    /// <remarks>
    /// Read once, when the band starts, rather than per move. A modifier picked up halfway through a drag
    /// would change what the rectangle already crossed means, which is a selection nobody could predict.
    /// </remarks>
    private bool bandAdds;

    public HostsScreen()
    {
        InitializeComponent();

        // Wired here rather than in the markup because it is a gesture rather than a binding, which is how
        // the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
        // every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
        // password box above it, and a host that asks for a password still needs it typed first.
        HostGrid.DoubleTapped += OnHostActivated;

        // And a group card opens the group, on the same gesture, for the same reason: going inside
        // something by double-clicking it is what the transfers screen's directories do and what every file
        // manager does. One click used to open a group, which made the card that names a group and the
        // control that narrows the grid to it the same press — so there was no way to select a group in
        // order to rename it without also losing sight of every host outside it.
        GroupGrid.DoubleTapped += OnGroupActivated;

        // Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
        // Bubbling would work for the drag but not for the menu: by then the control has already decided
        // what is selected, and the menu is about to open against it. It is also what lets a modifier click
        // stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
        // moving its own selection onto it.
        HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
        HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);

        // And the group cards answer a right click the same way, for the same reason: their menu's commands
        // read the vault's group selection, and without this they would act on whichever card was selected
        // before — or, with none, on the group the trail ends with, which is not on screen at all.
        GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);

        HostGrid.PointerMoved += OnPointerMoved;
        HostGrid.PointerReleased += OnPointerReleased;
        HostGrid.PointerCaptureLost += OnPointerCaptureLost;

        WireTheBand();

        // The host grid is where a drag starts and the group cards are where it lands. They used to be the
        // same control: the target was a heading among the cards, and with the headings gone the group cards
        // are the only thing on this screen that names a group. A card dropped onto another card is refused
        // rather than filed beside it — in a grid with no headings there is nothing to say which group that
        // would be, and a gesture whose result you cannot see before you let go is one that files machines
        // somewhere the user did not intend.
        DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
        DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
        DragDrop.AddDropHandler(GroupGrid, OnDrop);

        // Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
        // one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
        // over the grid is reported at all, and that makes every card inside it a drop target as far as the
        // platform is concerned. This is where all of them but a group card are turned down — and where a
        // drag held at the top or bottom edge pulls the grid towards the target.
        DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
    }

    /// <summary>The band's own wiring, and the two keys that go with a set.</summary>
    /// <remarks>
    /// Split out of the constructor rather than sitting in it, and only because the constructor is at the
    /// length this repository's analyser allows for one. Everything here arrived together: it is the whole
    /// of choosing more than one card with a pointer.
    /// </remarks>
    private void WireTheBand()
    {
        // Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
        // something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
        // in it. The grid takes focus on a press, so the keys work from the moment anything has been
        // touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
        HostGrid.KeyDown += OnGridKey;

        // ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
        // mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
        // the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
        // reason the list's own press is, and it runs first, so it has to recognise a press on a card and
        // leave it alone.
        Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
        Scroll.PointerMoved += OnBandMoved;
        Scroll.PointerReleased += OnBandReleased;
        Scroll.PointerCaptureLost += OnBandCaptureLost;
    }

    /// <remarks>
    /// Null before the window has handed one over, and while the previewer is showing this control with no
    /// data context at all. Every handler below checks rather than assuming.
    /// </remarks>
    private VaultViewModel? Vault => DataContext as VaultViewModel;

    /// <summary>
    /// Where the keyboard should land when the terminal hands it back.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Exposed as a property rather than left for the window to find by name, because the name is inside
    /// this control's template and the window cannot see it.
    /// </para>
    /// <para>
    /// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
    /// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
    /// something that is not there would swallow it: the terminal would let go and nothing would take it.
    /// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
    /// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
    /// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
    /// answer then, and it is a good one: it is where they were typing.
    /// </para>
    /// </remarks>
    internal IInputElement KeyboardTarget =>
        Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;

    /// <remarks>
    /// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
    /// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
    /// returning a task nothing observes.
    /// </remarks>
    private void OnHostActivated(object? sender, TappedEventArgs e)
    {
        // Only over a card. A double-tap on the space around them must not connect to whichever host was
        // selected before — which is what an unguarded handler would do, on a machine the user is not even
        // pointing at.
        if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
        {
            _ = vault.ConnectCommand.ExecuteAsync(null);
        }
    }

    /// <summary>
    /// Opens the group card that was double-clicked.
    /// </summary>
    /// <remarks>
    /// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
    /// being guarded against: an unguarded handler would open whichever group happened to be selected when
    /// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
    /// </remarks>
    private void OnGroupActivated(object? sender, TappedEventArgs e)
    {
        if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
        {
            vault.OpenGroupCommand.Execute(group);
        }
    }

    /// <summary>
    /// Points the menu at whatever was right-clicked.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The menu's commands read the vault's host selection, and a right click does not move it — which would
    /// mean a menu that quietly acted on whichever host happened to be selected instead of the one under the
    /// pointer. Deleting the wrong machine is the version of that mistake worth designing against.
    /// </para>
    /// <para>
    /// ◆ <b>A right click outside the ticked set drops the set, and one inside it keeps it.</b> That is the
    /// same rule as the plain press below, and it is what makes the two halves of the menu safe to draw from
    /// one markup: the entries about a set and the entries about a selection are never both meaningful, so
    /// Delete… can never be a question about the card under the pointer asked while six others sit ticked
    /// behind the menu.
    /// </para>
    /// <para>
    /// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
    /// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
    /// </para>
    /// </remarks>
    private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
    {
        if (Vault is not { } vault || RowUnder(e.Source) is not HostRowViewModel row)
        {
            e.Handled = true;
            return;
        }

        if (vault.IsChoosingHosts && !row.IsChosen)
        {
            vault.ClearHostChoiceCommand.Execute(null);
        }

        if (!vault.IsChoosingHosts)
        {
            vault.SelectedSidebarRow = row;
        }
    }

    /// <summary>
    /// Points the group menu at whatever was right-clicked.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
    /// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
    /// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
    /// contents are showing rather than the one the pointer is on. This menu is the only way to either of
    /// them now, so aiming it is the whole of aiming them.
    /// </para>
    /// <para>
    /// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
    /// the fallback is exactly what would make the menu look like it worked there.
    /// </para>
    /// </remarks>
    private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
    {
        if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
        {
            e.Handled = true;
            return;
        }

        vault.SelectedGroup = row;
    }

    /// <summary>
    /// Ticks cards, or remembers a press that may become a drag.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
    /// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
    /// that also selected would light the card it had just unticked, and the drawer would open on a machine
    /// the user was removing from a set. Handled on the way down is the only place that can be said —
    /// by the time the press bubbles the control has already answered it.
    /// </para>
    /// <para>
    /// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
    /// file manager has and the reason the two selections on this screen can never disagree: after an
    /// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
    /// to the release, because the press may be the start of a drag of the whole set; see
    /// <see cref="collapse"/>.
    /// </para>
    /// <para>
    /// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
    /// The difference is real: there a sheet is over the list and the row under the finger is not what was
    /// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
    /// things that can then be done to a set each refuse for themselves, and say so.
    /// </para>
    /// </remarks>
    private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
    {
        Forget();

        if (Vault is not { } vault
            || !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
            || RowUnder(e.Source) is not HostRowViewModel row)
        {
            return;
        }

        var control = e.KeyModifiers.HasFlag(KeyModifiers.Control);

        if (e.KeyModifiers.HasFlag(KeyModifiers.Shift))
        {
            vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);

            HostGrid.Focus();
            e.Handled = true;

            return;
        }

        if (control)
        {
            vault.ToggleHostChoiceCommand.Execute(row);
            anchor = row;

            HostGrid.Focus();
            e.Handled = true;

            return;
        }

        anchor = row;

        // Read before anything is cleared, since clearing is one of the two things it decides.
        if (row.IsChosen)
        {
            collapse = row;
            pickedUp = vault.ChosenHosts;
        }
        else
        {
            if (vault.IsChoosingHosts)
            {
                vault.ClearHostChoiceCommand.Execute(null);
            }

            pickedUp = [row];
        }

        press = e;
        origin = e.GetPosition(HostGrid);
    }

    /// <remarks>
    /// The drag is started from the remembered press once the pointer has travelled far enough — see
    /// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
    /// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
    /// </remarks>
    private void OnPointerMoved(object? sender, PointerEventArgs e)
    {
        if (press is not { } pressed || pickedUp.Count == 0)
        {
            return;
        }

        if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
        {
            Forget();
            return;
        }

        var moved = e.GetPosition(HostGrid) - origin;

        if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
        {
            return;
        }

        var carried = pickedUp;

        Forget();

        _ = DragAsync(pressed, carried);
    }

    /// <remarks>
    /// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
    /// what every other click on a card means. See <see cref="collapse"/>.
    /// </remarks>
    private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
    {
        if (collapse is not null && Vault is { } vault)
        {
            vault.ClearHostChoiceCommand.Execute(null);
        }

        Forget();
    }

    private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();

    /// <summary>
    /// Esc drops the set, and Ctrl+A is every card on the screen.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
    /// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
    /// including the thirty-two not being shown would be the worst possible input to Delete.
    /// </para>
    /// <para>
    /// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
    /// it cancels the terminal's own things — and swallowing it here while nothing is ticked would take it
    /// away from whichever of those the user meant.
    /// </para>
    /// </remarks>
    private void OnGridKey(object? sender, KeyEventArgs e)
    {
        if (Vault is not { } vault)
        {
            return;
        }

        if (e.Key is Key.Escape && vault.IsChoosingHosts)
        {
            vault.ClearHostChoiceCommand.Execute(null);
            e.Handled = true;

            return;
        }

        if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
        {
            vault.ChooseHosts(vault.VisibleHosts, replacing: true);
            anchor = vault.VisibleHosts[0];
            e.Handled = true;
        }
    }

    /// <summary>
    /// Starts a band on the empty space between and below the cards.
    /// </summary>
    /// <remarks>
    /// <para>
    /// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
    /// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
    /// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
    /// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
    /// above it moves the ceiling with no code here changing.
    /// </para>
    /// <para>
    /// The pointer is captured, because a band is nearly always dragged past the edge of the control it
    /// started in — down onto the status bar, or off the window entirely — and without capture the moves
    /// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
    /// nothing left to take it down.
    /// </para>
    /// <para>
    /// Nothing is ticked or unticked here. A press is not yet a band and may never become one; what a click
    /// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
    /// </para>
    /// </remarks>
    private void OnBandPressed(object? sender, PointerPressedEventArgs e)
    {
        EndBand();

        if (Vault is null
            || !e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed
            || TakesThePress(e.Source))
        {
            return;
        }

        var at = e.GetPosition(Scroll);

        if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
        {
            return;
        }

        banding = true;
        bandFrom = at;
        bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);

        e.Pointer.Capture(Scroll);
        HostGrid.Focus();
    }

    /// <remarks>
    /// The rectangle is drawn and the set is rewritten on every move, so what is ticked is what the band is
    /// over at that moment — including the cards it has just been pulled back off. A band that only ever
    /// added would make overshooting it unrecoverable without starting again. Ctrl is the exception and is
    /// the reason it is a mode read once: with it held the band adds to what was already ticked, which is how
    /// a second run is picked up without losing the first.
    /// </remarks>
    private void OnBandMoved(object? sender, PointerEventArgs e)
    {
        if (!banding || Vault is not { } vault)
        {
            return;
        }

        if (!e.GetCurrentPoint(Scroll).Properties.IsLeftButtonPressed)
        {
            EndBand();
            return;
        }

        var at = e.GetPosition(Scroll);

        var box = new Rect(
            Math.Min(bandFrom.X, at.X),
            Math.Min(bandFrom.Y, at.Y),
            Math.Abs(at.X - bandFrom.X),
            Math.Abs(at.Y - bandFrom.Y));

        if (box.Width < DragThreshold && box.Height < DragThreshold)
        {
            return;
        }

        Band.Margin = new Thickness(box.X, box.Y, 0, 0);
        Band.Width = box.Width;
        Band.Height = box.Height;
        Band.IsVisible = true;

        vault.ChooseHosts(CardsIn(box), replacing: !bandAdds);
    }

    /// <remarks>
    /// A press and a release with no band between them is a click on the space around the cards, and that
    /// drops the set — the same thing a plain click on a card does, and the way out of selection mode for
    /// anybody who never finds Esc. Where a band <em>was</em> drawn the moves have already said what is
    /// ticked, and re-applying it here would only repeat the last one.
    /// </remarks>
    private void OnBandReleased(object? sender, PointerReleasedEventArgs e)
    {
        if (!banding)
        {
            return;
        }

        var drawn = Band.IsVisible;

        EndBand();
        e.Pointer.Capture(null);

        if (!drawn && Vault is { IsChoosingHosts: true } vault)
        {
            vault.ClearHostChoiceCommand.Execute(null);
        }
    }

    /// <remarks>
    /// The band is taken down and nothing is decided. Capture is lost when a menu opens over the drag, when
    /// the window is deactivated, and when the platform simply takes it — none of which is the user saying
    /// what they wanted; leaving the rectangle up would be the only visible consequence.
    /// </remarks>
    private void OnBandCaptureLost(object? sender, PointerCaptureLostEventArgs e) => EndBand();

    private void EndBand()
    {
        banding = false;
        Band.IsVisible = false;
    }

    /// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
    /// <remarks>
    /// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
    /// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
    /// the run, and would tick nothing at all for a band drawn down the middle of a column.
    /// </remarks>
    private List<HostRowViewModel> CardsIn(Rect box)
    {
        var hit = new List<HostRowViewModel>();

        foreach (var container in HostGrid.GetRealizedContainers())
        {
            if (container.DataContext is HostRowViewModel row
                && container.TranslatePoint(default, Scroll) is { } corner
                && box.Intersects(new Rect(corner, container.Bounds.Size)))
            {
                hit.Add(row);
            }
        }

        return hit;
    }

    /// <summary>Whether what was pressed is something that answers a press itself.</summary>
    /// <remarks>
    /// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
    /// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
    /// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
    /// tick everything it passed.
    /// </remarks>
    private static bool TakesThePress(object? source) => source is Visual visual
        && visual.GetSelfAndVisualAncestors().Any(element =>
            element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);

    /// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
    private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
    {
        var carried = new DataTransfer();
        carried.Add(DataTransferItem.Create(HostFormat, rows));

        try
        {
            // ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
            // thread's.
            await DragDrop
                .DoDragDropAsync(pressed, carried, DragDropEffects.Move)
                .ConfigureAwait(true);
        }
        finally
        {
            // Whatever the drop did or did not do. A mark left behind would be a card that looks like a
            // target for a drag that ended somewhere else entirely.
            Unmark();
        }
    }

    /// <summary>
    /// Says whether what is under the pointer would take these hosts, and marks it if it would.
    /// </summary>
    /// <remarks>
    /// A drag whose hosts are all already in the group under the pointer is refused, which is not pedantry:
    /// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
    /// would do something and then does nothing is worse than one that says so while it is still in the air.
    /// A set with even one host from somewhere else is accepted, because filing that one is a real move.
    /// </remarks>
    private void OnDragOver(object? sender, DragEventArgs e)
    {
        e.Handled = true;

        if (Target(e) is not { } target)
        {
            e.DragEffects = DragDropEffects.None;
            Unmark();
            return;
        }

        e.DragEffects = DragDropEffects.Move;
        Mark(target.Container);
    }

    private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();

    /// <summary>
    /// Carries the grid under a drag that is over the cards rather than over a group.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
    /// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
    /// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
    /// grid down towards the target, which is what every file manager does with a drag near the edge of a
    /// list.
    /// </para>
    /// <para>
    /// A step per event rather than a timer, and that is a real limit rather than a simplification: a
    /// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
    /// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
    /// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
    /// </para>
    /// <para>
    /// Reached only when the group cards did not handle the event first, which is what makes refusing the
    /// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
    /// everything that is not a group.
    /// </para>
    /// </remarks>
    private void OnDragOverScroll(object? sender, DragEventArgs e)
    {
        if (e.DataTransfer.TryGetValue(HostFormat) is null)
        {
            return;
        }

        e.Handled = true;
        e.DragEffects = DragDropEffects.None;
        Unmark();

        var at = e.GetPosition(Scroll).Y;
        var height = Scroll.Bounds.Height;

        var step = at switch
        {
            _ when at < EdgeBand => -EdgeStep,
            _ when at > height - EdgeBand => EdgeStep,
            _ => 0,
        };

        if (step == 0)
        {
            return;
        }

        var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);

        Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
    }

    /// <remarks>
    /// <para>
    /// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
    /// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
    /// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
    /// a group belongs to one vault — and reports a count rather than forty status lines in a row.
    /// </para>
    /// <para>
    /// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
    /// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
    /// task nothing observes.
    /// </para>
    /// </remarks>
    private void OnDrop(object? sender, DragEventArgs e)
    {
        e.Handled = true;
        Unmark();

        if (Vault is not { } vault || Target(e) is not { } target)
        {
            e.DragEffects = DragDropEffects.None;
            return;
        }

        e.DragEffects = DragDropEffects.Move;

        if (target.Hosts.Count > 1)
        {
            vault.FileChosenHostsUnderCommand.Execute(target.Group);
            return;
        }

        vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
    }

    /// <summary>
    /// Where a drag currently is, or null if it is over nothing that would take it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// One kind of target: a group card. It is the control that already answers "which group", it is drawn
    /// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
    /// dropped on is what it says on it. The space around the cards takes nothing.
    /// </para>
    /// <para>
    /// A group the vault no longer has is read as no group at all, which is what the list already does with
    /// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
    /// rather than here, so the rule has one home.
    /// </para>
    /// </remarks>
    private static DropTarget? Target(DragEventArgs e)
    {
        if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
            || Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
            || dragged.All(row => row.Host.GroupId == group.EntityId))
        {
            return null;
        }

        return new DropTarget(dragged, group, container);
    }

    private void Mark(ListBoxItem container)
    {
        if (ReferenceEquals(marked, container))
        {
            return;
        }

        Unmark();

        marked = container;
        marked.Classes.Add("droptarget");
    }

    private void Unmark()
    {
        marked?.Classes.Remove("droptarget");
        marked = null;
    }

    /// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
    private void Forget()
    {
        press = null;
        pickedUp = [];
        collapse = null;
    }

    /// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
    private static object? RowUnder(object? source) => Container(source)?.DataContext;

    /// <remarks>
    /// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
    /// is what an event's source is. Anything not inside an item, which is the space around the cards,
    /// yields null.
    /// </remarks>
    private static ListBoxItem? Container(object? source) => source is Visual visual
        ? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
        : null;

    /// <summary>A drag in flight, and where it would land.</summary>
    /// <remarks>
    /// The group is the card rather than its id, which it was while only one host could be dragged: the
    /// command that files a set takes the card, because the refusal it makes is about which vault the group
    /// is in. The way out of a group is still the host's own editor, which is the one place "no group" can be
    /// said in words.
    /// </remarks>
    private sealed record DropTarget(
        IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
}
