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 board 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>
/// ◆ v5: THE GROUP-CARD HALF OF THIS SUITE IS GONE, AND IT IS NOT REPLACED HERE. The desktop used to hold a
/// wrap of group cards above the hosts — one level of the tree at a time, opened by a double-click, filed
/// into by dragging a host card onto one. All of that left with the cards: v5 draws every group as a
/// section heading instead, see <c>VaultViewModel.HostSections</c>, and a host is filed through its own
/// editor or the chosen-hosts menu's "Change group…" rather than a drag. The card-navigation surface this
/// was written against — <c>OpenGroupCommand</c>, <c>VisibleGroups</c>, <c>GroupTrail</c>,
/// <c>SelectedGroup</c>, <c>GroupCrumbViewModel</c>, and the drag command <c>MoveHostToGroupCommand</c> dragged
/// onto — is no longer on <c>VaultViewModel</c> at all, confirmed unreachable from both heads' markup before
/// removal; <c>GroupFilter</c> stays, since group-heading commands still fall back to it. Section-level
/// coverage — flattening order, the headerless invariant, per-section collapse, filter composition, monogram
/// stability — is in <c>HostSectionsTests</c> instead, which is where a heading now belongs.
/// </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 menu's halves 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.
/// </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 board 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 OnTheBoardAsync((screen, window) =>
        {
            var first = Row(vault, "prod-db");
            var other = Row(vault, "stage-web");

            vault.SelectedHost = first;

            var card = CardFor(screen, other);
            RightClick(card, window);

            vault.SelectedHost.ShouldBeSameAs(other);

            var menu = SectionMenuFor(card).ShouldNotBeNull();
            menu.IsOpen.ShouldBeTrue();

            // The commands are the vault's, which is the other half of putting the menu on each section's
            // own list rather than in the card's 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.
            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>
    /// The space around the cards and below them is part of <c>Board</c> rather than of any one section's
    /// list, 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. Raised on <c>Board</c> itself, which is where the code-behind's tunnelled
    /// handler is attached; see <c>HostsScreen.axaml.cs</c>.
    /// </remarks>
    [Fact]
    public async Task ARightClickOffAnyCardOpensNothingAndMovesNothing()
    {
        await OnTheBoardAsync((screen, _) =>
        {
            var selected = Row(vault, "prod-db");
            vault.SelectedHost = selected;

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

            vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");

            screen.GetVisualDescendants().OfType<ListBox>()
                .Where(list => list.Classes.Contains("sectioncards"))
                .Select(list => list.ContextMenu)
                .OfType<ContextMenu>()
                .ShouldAllBe(menu => !menu.IsOpen, "no section's menu opened over the empty space");
        });
    }

    /// <summary>
    /// Choosing a host costs nothing, and the pencil on its card is what spends the 320 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>#Board</c> path that resolves to nothing compiles, draws, and does nothing when pressed. See the
    /// remark on that idiom at the top of <c>HostsScreen.axaml</c>.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheDrawerOpensOnThePencilRatherThanOnTheSelection()
    {
        await OnTheBoardAsync((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.
    /// </remarks>
    [Fact]
    public async Task LosingTheSelectionClosesTheDrawerAndDoesNotArmItAgain()
    {
        await OnTheBoardAsync((_, _) =>
        {
            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 a section 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 a section's own <c>ListBox</c> 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 OnTheBoardAsync((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");
            vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were 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 board's own — <c>VaultViewModel.HostBoardOrder</c> — so "between" means between as the cards are
    /// laid out, across sections if the run crosses one.
    /// </remarks>
    [Fact]
    public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
    {
        await AddHostAsync("dev-box");

        await OnTheBoardAsync((screen, window) =>
        {
            var order = vault.HostBoardOrder.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.
    /// </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 OnTheBoardAsync((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 right click outside the set is what drops it, so
    /// the two are never both meaningful.
    /// </remarks>
    [Fact]
    public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
    {
        await OnTheBoardAsync((screen, window) =>
        {
            var ticked = Row(vault, "prod-db");
            var other = Row(vault, "stage-web");

            var tickedCard = CardFor(screen, ticked);

            Click(tickedCard, window, RawInputModifiers.Control);

            RightClick(tickedCard, window);

            var menu = SectionMenuFor(tickedCard).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();

            var otherCard = CardFor(screen, other);
            RightClick(otherCard, 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");

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

    /// <remarks>
    /// Ctrl+A is every card <em>on the board</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
    /// <c>Board</c> 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 OnTheBoardAsync((screen, window) =>
        {
            vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");

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

            screen.Board.IsFocused.ShouldBeTrue("the keys are the board'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();
        });
    }

    // ---- Helpers ----

    private static void RightClick(Visual card, Visual window)
    {
        var at = Centre(card, 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 OnTheBoardAsync(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);

    /// <summary>The card for a host, out of whichever section's own list is drawing it.</summary>
    private static ListBoxItem CardFor(Visual screen, HostRowViewModel row) =>
        screen.GetVisualDescendants()
            .OfType<ListBoxItem>()
            .First(item => ReferenceEquals(item.DataContext, row));

    /// <summary>The <c>ContextMenu</c> of the section list a card belongs to.</summary>
    /// <remarks>
    /// Every section draws its own <c>ListBox</c> — see the remarks on <c>HostSectionViewModel</c> — so
    /// there is no longer one menu for the whole board; this is what "the menu" now means for a given card.
    /// </remarks>
    private static ContextMenu? SectionMenuFor(ListBoxItem card) =>
        card.FindAncestorOfType<ListBox>()?.ContextMenu;

    private static HostRowViewModel Row(VaultViewModel vault, string label) =>
        vault.Hosts.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");

    /// <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 board and a selection to move off. Neither host
    /// is filed under the group — it stays in the seed only because several of the suites in this project
    /// still ask for one to exist, empty though it is here.
    /// </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);
    }
}
