using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
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 all three of
/// that menu's commands read the vault's host 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 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.
            var edit = menu.Items.OfType<MenuItem>().Single(item => 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>
    /// A heading is an item in the same list and the control will happily select it, but it is not a host —
    /// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
    /// or act on a machine somewhere else in the grid.
    /// </remarks>
    [Fact]
    public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
    {
        await OnTheGridAsync((screen, window) =>
        {
            var selected = Row(vault, "prod-db");
            vault.SelectedHost = selected;

            var heading = screen.HostGrid
                .GetVisualDescendants()
                .OfType<ListBoxItem>()
                .First(item => item.DataContext is SidebarGroupHeader);

            RightClick(heading, window);

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

    /// <remarks>
    /// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
    /// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
    /// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
    /// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
    /// binding, which is not this application's code.
    /// </remarks>
    [Fact]
    public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
    {
        var production = vault.Groups.Single();

        vault.MoveHostToGroupCommand.Execute(
            new HostGroupMove(Row(vault, "prod-db"), production.EntityId));

        vault.GroupFilter = production;

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

        vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
        vault.IsFilteredByGroup.ShouldBeTrue();

        vault.ClearGroupFilterCommand.Execute(null);

        vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
        vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
    }

    // ---- Helpers ----

    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);
    }

    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 CardFor(Visual screen, HostRowViewModel host) =>
        screen.GetVisualDescendants()
            .OfType<ListBoxItem>()
            .First(item => ReferenceEquals(item.DataContext, host));

    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");

    /// <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);
    }
}
