using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
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 quick-connect palette answers a keyboard and a pointer.
/// </summary>
/// <remarks>
/// <para>
/// This is the suite the palette shipped without, and the reason it shipped without one is that all of this
/// used to live on <c>MainWindow</c> — which cannot be shown here at all, because attaching the terminal's
/// WebView initialises WebView2 on a thread it refuses. See
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. A <c>UserControl</c> hosts in a bare
/// window, takes real key and pointer input, and can therefore be held to what it promises.
/// </para>
/// <para>
/// Three things were wrong and each has a test here: nothing answered a press outside the palette, so the one
/// gesture everybody tries first did nothing; the caret never reached the query box, because the window
/// focused it from the view model's <c>PropertyChanged</c> — ahead of the binding that reveals the control,
/// and focus on a collapsed control is a no-op; and the keys were answered only by a handler on the window,
/// which anything on the route could have taken first.
/// </para>
/// <para>
/// A real <see cref="MainWindowViewModel"/> over a real unlocked vault, for the same reason the layout suite
/// uses one: compiled bindings resolve against the declared type, and the palette's list is populated by the
/// vault's own hosts. Nothing here reaches a network — the connect the Enter test performs fails inside the
/// vault's own error handling, which is fine, because what Enter promises is to take the highlighted result
/// and close.
/// </para>
/// </remarks>
public sealed class QuickConnectTests : 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 MainWindowViewModel shell = null!;

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

    /// <inheritdoc />
    public async ValueTask InitializeAsync()
    {
        caches = ClientCacheFactory.ForMemory($"palette-{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();

        shell = new MainWindowViewModel(
            ClientPaths.Default,
            caches,
            workspace,
            knownHosts,
            Substitute.For<IDeviceKeyStore>(),
            (_, _) => throw new NotSupportedException("nothing here signs in"),
            TimeProvider.System,
            // Never asked for a session: the palette searches the host list and connects through the
            // vault's own command, and nothing on this screen transfers a file.
            Substitute.For<ISftpSessionFactory>(),
            CheapProfile)
        {
            // The state the palette is only ever open in. Assigned rather than reached through the unlock
            // path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
            State = ShellState.Unlocked,
            Vault = vault,
        };
    }

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

    /// <remarks>
    /// The gesture everybody tries first, and the one that did nothing at all: the wash took no pointer input,
    /// so the only ways out of the palette were a key and the button that opened it.
    /// </remarks>
    [Fact]
    public async Task APressOnTheWashClosesThePalette()
    {
        await OnThePaletteAsync((_, window) =>
        {
            // The bottom-left corner: the card is 520 wide, centred, and starts 90 pixels down, so nothing
            // here belongs to it.
            window.MouseDown(new Point(12, 520), MouseButton.Left);

            shell.IsSearching.ShouldBeFalse();
        });
    }

    /// <remarks>
    /// The other half of the same rule, and the one that makes it worth a handler rather than a press anywhere
    /// closing: a press on the card bubbles through the wash on its way out, so a handler that did not check
    /// where the press started would close the palette the moment somebody clicked into the box.
    /// </remarks>
    [Fact]
    public async Task APressOnTheCardDoesNotClose()
    {
        await OnThePaletteAsync((palette, window) =>
        {
            window.MouseDown(Centre(palette.QueryBox, window), MouseButton.Left);

            shell.IsSearching.ShouldBeTrue();
        });
    }

    [Fact]
    public async Task EscapeClosesThePalette()
    {
        await OnThePaletteAsync((palette, window) =>
        {
            palette.QueryBox.Focus().ShouldBeTrue();

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

            shell.IsSearching.ShouldBeFalse();
        });
    }

    /// <remarks>
    /// The second assertion is the whole reason the selection is moved by hand rather than by letting the list
    /// take focus: a palette whose arrow keys moved the caret out of the query box would stop receiving the
    /// next character typed.
    /// </remarks>
    [Fact]
    public async Task TheArrowsMoveTheSelectionAndLeaveTheKeyboardInTheBox()
    {
        await OnThePaletteAsync((palette, window) =>
        {
            palette.QueryBox.Focus().ShouldBeTrue();

            shell.SearchResults.Count.ShouldBeGreaterThan(2, "an empty list would prove nothing");
            shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);

            window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
            shell.SelectedSearchResult.ShouldBe(shell.SearchResults[1]);

            window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
            shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);

            // Clamped rather than wrapped, which is the palette's own rule.
            window.KeyPressQwerty(PhysicalKey.ArrowUp, RawInputModifiers.None);
            shell.SelectedSearchResult.ShouldBe(shell.SearchResults[0]);

            palette.QueryBox.IsFocused.ShouldBeTrue("the arrows must not move the caret out of the box");
        });
    }

    /// <remarks>
    /// What Enter promises is to take the highlighted row: the palette closes and the vault is pointed at that
    /// host. The connection it then asks for fails in this suite — there is no server and no shell — and it
    /// fails inside the vault's own handling, which is the point of connecting through the vault's command
    /// rather than opening a session from the palette.
    /// </remarks>
    [Fact]
    public async Task EnterTakesTheHighlightedResult()
    {
        await OnThePaletteAsync((palette, window) =>
        {
            palette.QueryBox.Focus().ShouldBeTrue();

            window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
            var highlighted = shell.SelectedSearchResult.ShouldNotBeNull();

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

            shell.IsSearching.ShouldBeFalse();
            vault.SelectedHost?.EntityId.ShouldBe(highlighted.EntityId);
        });
    }

    /// <remarks>
    /// The pointer's version of Enter, and the gesture a list of hosts under a search box plainly looks like
    /// it offers. It did not: a click moved the highlight and left the palette open over a choice that had
    /// already been made, so the second thing everybody tried was to click and then press Enter.
    /// </remarks>
    [Fact]
    public async Task ClickingAResultConnectsToIt()
    {
        await OnThePaletteAsync((palette, window) =>
        {
            var wanted = shell.SearchResults[2];
            var row = RowFor(palette, wanted);

            window.MouseDown(Centre(row, window), MouseButton.Left);
            window.MouseUp(Centre(row, window), MouseButton.Left);

            shell.IsSearching.ShouldBeFalse("connecting closes the palette, as Enter does");
            vault.SelectedHost?.EntityId.ShouldBe(wanted.EntityId);
        });
    }

    /// <remarks>
    /// The palette searches every vault the session holds a key for, so a row has to say which one it came
    /// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
    /// and Enter takes whichever the ranking happened to put first.
    /// <para>
    /// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
    /// active vault's rows come first — and the list virtualises, so the row this is about might never be
    /// realised. Narrowing to it also proves the search reaches past the active vault at all.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task AResultSaysWhichVaultItCameOutOf()
    {
        await SeedSharedHostAsync();

        await OnThePaletteAsync((palette, window) =>
        {
            shell.SearchText = "prod-db";
            Relayout(window);

            var found = shell.SearchResults.ShouldHaveSingleItem();
            found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");

            VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
        });
    }

    /// <remarks>
    /// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
    /// every row of a session that has only one is the same fact repeated, which is noise rather than a
    /// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
    /// than leave a gap above the kind word.
    /// </remarks>
    [Fact]
    public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
    {
        await OnThePaletteAsync((palette, _) =>
        {
            var first = shell.SearchResults[0];

            first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
            VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
        });
    }

    /// <remarks>
    /// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
    /// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
    /// control, and <c>Focus()</c> on a collapsed control is a no-op that is never replayed. Becoming visible
    /// is the moment that cannot be too early, so that is where the palette takes the keyboard — and this is
    /// the test that says so.
    /// </remarks>
    [Fact]
    public async Task ThePaletteTakesTheKeyboardWhenItAppears()
    {
        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var elsewhere = new TextBox();
                var palette = new QuickConnect { DataContext = shell, IsVisible = false };

                var window = new Window { Content = new Panel { Children = { elsewhere, palette } } };
                LayoutHarness.Settle(window, 900, 600);

                try
                {
                    elsewhere.Focus().ShouldBeTrue();

                    shell.ToggleSearchCommand.Execute(null);
                    palette.IsVisible = true;

                    // The layout pass the application's dispatcher would run anyway. Without it the query box
                    // is not in the visual tree yet, which is the whole reason the palette defers this.
                    Dispatcher.UIThread.RunJobs();

                    palette.QueryBox.IsFocused.ShouldBeTrue();
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    // ---- Helpers ----

    /// <summary>Opens the palette in a window the size the application's is, and runs one body against it.</summary>
    private Task OnThePaletteAsync(Action<QuickConnect, Window> body) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                shell.ToggleSearchCommand.Execute(null);
                shell.IsSearching.ShouldBeTrue("every case here starts with the palette open");

                var palette = new QuickConnect { DataContext = shell };
                var window = new Window { Content = palette };
                LayoutHarness.Settle(window, 900, 600);

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

    /// <summary>The list row showing one result.</summary>
    private static ListBoxItem RowFor(Visual palette, HostRowViewModel host) =>
        palette.GetVisualDescendants()
            .OfType<ListBoxItem>()
            .First(item => ReferenceEquals(item.DataContext, host));

    /// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
    private static List<string> VisibleTexts(Visual row) =>
        row.GetVisualDescendants()
            .OfType<TextBlock>()
            .Where(text => text.IsEffectivelyVisible)
            .Select(text => text.Text ?? string.Empty)
            .ToList();

    /// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
    /// <remarks>
    /// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
    /// finds nothing to look at.
    /// </remarks>
    private static void Relayout(Window window)
    {
        Dispatcher.UIThread.RunJobs();
        window.UpdateLayout();
    }

    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>Enough hosts that the arrow keys have somewhere to go.</remarks>
    private async Task SeedAsync()
    {
        for (var i = 0; i < 6; i++)
        {
            vault.NewHostCommand.Execute(null);
            vault.EditorLabel = $"host-{i}";
            vault.EditorHostname = $"host-{i}.internal";
            vault.EditorUsername = "deploy";
            await vault.SaveHostCommand.ExecuteAsync(null);
        }

        await vault.LoadAsync(Token);
    }

    /// <summary>
    /// Adds a second, shared vault to the fixture's session and files one host into it.
    /// </summary>
    /// <remarks>
    /// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
    /// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
    /// shell has none. The key is generated on this machine either way, so what the session ends up holding
    /// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
    /// </remarks>
    private async Task SeedSharedHostAsync()
    {
        using var teamServer = new StubTeamServer();

        var shared = await session.CreateTeamVaultAsync(
            teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);

        await vault.LoadAsync(Token);

        vault.SelectedTargetVault =
            vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);

        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = "prod-db";
        vault.EditorHostname = "db.internal";
        vault.EditorUsername = "deploy";

        await vault.SaveHostCommand.ExecuteAsync(null);

        vault.IsEditing.ShouldBeFalse(vault.Status);

        await vault.LoadAsync(Token);
    }
}
