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>
/// <see cref="VaultViewModel.HostSections"/>: the desktop board's own flattening, one section per group.
/// </summary>
/// <remarks>
/// <para>
/// Split out of <see cref="HostGridTests"/> rather than folded into it, because none of what is here needs a
/// realized <c>HostsScreen</c> — it is <see cref="VaultViewModel"/> alone, the same surface
/// <c>VaultViewModel.RebuildHostSections</c>, <c>FlattenIntoSections</c> and the monogram properties on
/// <see cref="HostRowViewModel"/> already carry doc comments for. This is where those comments are held to
/// account.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason every other suite in this
/// project uses one: compiled bindings resolve against the declared type, and a stand-in would still have to
/// be it.
/// </para>
/// </remarks>
public sealed class HostSectionsTests : 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($"host-sections-{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);
    }

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

    /// <remarks>
    /// The invariant <c>HostsScreen.axaml</c> depends on for a groupless keychain to look exactly as it did
    /// before groups existed: one section, and its <c>Header</c> is null rather than a heading with nothing
    /// worth saying. See <c>HostSectionViewModel.HasHeader</c> and the XAML's own <c>IsVisible</c> on it.
    /// </remarks>
    [Fact]
    public async Task AGrouplessKeychainDrawsOneHeaderlessSection()
    {
        await AddHostAsync("prod-db");
        await AddHostAsync("stage-web");

        vault.HostSections.ShouldHaveSingleItem();
        vault.HostSections[0].HasHeader.ShouldBeFalse();
        vault.HostSections[0].Hosts.Select(row => row.Label)
            .ShouldBe(["prod-db", "stage-web"], ignoreOrder: true);
    }

    /// <summary>
    /// Every group is a section, in label order, "No group" first — the flattening the phone's
    /// <c>SidebarRows</c> has always drawn, adopted by the desktop board in v5.
    /// </summary>
    /// <remarks>
    /// The three groups are made out of label order on purpose — "zeta" before "alpha" before "mid" — so a
    /// pass that happened to preserve creation order rather than sorting would still be caught.
    /// </remarks>
    [Fact]
    public async Task SectionsAreInLabelOrderWithNoGroupFirst()
    {
        await AddGroupAsync("zeta");
        await AddGroupAsync("alpha");
        await AddGroupAsync("mid");

        await AddHostAsync("loose");
        await AddHostInGroupAsync("z-host", "zeta");
        await AddHostInGroupAsync("a-host", "alpha");
        await AddHostInGroupAsync("m-host", "mid");

        vault.HostSections.Select(section => section.Header?.Label ?? "No group")
            .ShouldBe(["No group", "alpha", "mid", "zeta"]);

        vault.HostSections.Single(section => string.Equals(section.Header?.Label, "alpha", StringComparison.Ordinal))
            .Hosts.Select(row => row.Label).ShouldBe(["a-host"]);
    }

    /// <summary>
    /// An empty group still gets a heading; a filter that empties a group's contents does not remove it, but
    /// the ungrouped heading disappears entirely once nothing is left under it.
    /// </summary>
    [Fact]
    public async Task AnEmptyNamedGroupKeepsItsHeadingAndAnEmptyUngroupedHeadingDropsAway()
    {
        await AddGroupAsync("empty-shelf");
        await AddHostInGroupAsync("only-host", "empty-shelf");

        vault.HostSections.Select(section => section.Header?.Label)
            .ShouldNotContain("No group", "nothing is ungrouped, so that heading is not drawn at all");

        vault.HostSections.Single(section => string.Equals(section.Header?.Label, "empty-shelf", StringComparison.Ordinal))
            .Hosts.Select(row => row.Label).ShouldBe(["only-host"]);
    }

    /// <remarks>
    /// Collapsing a section keeps its heading — the count on it is still real — and empties its own card
    /// list; expanding it again restores the cards. Both go through <c>ToggleGroupCommand</c>, the same
    /// command the heading's chevron button is bound to.
    /// </remarks>
    [Fact]
    public async Task TogglingASectionEmptiesItsCardsWithoutDroppingItsHeading()
    {
        await AddGroupAsync("production");
        await AddHostInGroupAsync("prod-db", "production");
        await AddHostInGroupAsync("prod-web", "production");

        var header = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal)).Header!;

        header.IsExpanded.ShouldBeTrue("nothing has folded it yet");

        vault.ToggleGroupCommand.Execute(header);

        var collapsed = vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal));
        collapsed.Header!.IsExpanded.ShouldBeFalse();
        collapsed.Header.Count.ShouldBe(2, "the heading still says how many are under it");
        collapsed.Hosts.ShouldBeEmpty("but the card list under it is folded away");

        vault.ToggleGroupCommand.Execute(collapsed.Header);

        vault.HostSections.Single(section => string.Equals(section.Header?.Label, "production", StringComparison.Ordinal))
            .Hosts.Select(row => row.Label).ShouldBe(["prod-db", "prod-web"], ignoreOrder: true);
    }

    /// <remarks>
    /// Collapse all folds every section at once and Expand all — the same command, its label swapped by
    /// <c>CollapseAllLabel</c> — brings every one of them back. See the remark on that property for why it
    /// reads "collapse" until every section already is one.
    /// </remarks>
    [Fact]
    public async Task CollapseAllFoldsEverySectionAndExpandAllRestoresThem()
    {
        await AddGroupAsync("alpha");
        await AddGroupAsync("beta");
        await AddHostInGroupAsync("a-host", "alpha");
        await AddHostInGroupAsync("b-host", "beta");
        await AddHostAsync("loose");

        vault.CollapseAllLabel.ShouldBe("Collapse all", "something is still open, so this is the fold action");

        vault.ToggleAllGroupsCommand.Execute(null);

        vault.HostSections.SelectMany(section => section.Hosts).ShouldBeEmpty("every section folded at once");
        vault.CollapseAllLabel.ShouldBe("Expand all");

        vault.ToggleAllGroupsCommand.Execute(null);

        vault.HostSections.SelectMany(section => section.Hosts).Select(row => row.Label)
            .ShouldBe(["loose", "a-host", "b-host"], ignoreOrder: true);
        vault.CollapseAllLabel.ShouldBe("Collapse all");
    }

    /// <summary>
    /// Group ▾, Tag ▾ and the find box all narrow the same board at once, and a host has to clear every one
    /// of them that is active — the toolbar's two flyouts are an AND with each other and with the box, even
    /// though a single flyout's own ticks are an OR among themselves.
    /// </summary>
    [Fact]
    public async Task GroupFilterTagFilterAndFindComposeAsAnIntersection()
    {
        await AddGroupAsync("alpha");
        await AddGroupAsync("beta");

        await AddHostInGroupWithTagAsync("a-prod", "alpha", "prod");
        await AddHostInGroupWithTagAsync("a-dev", "alpha", "dev");
        await AddHostInGroupWithTagAsync("b-prod", "beta", "prod");

        var alphaChoice = vault.GroupFilterChoices.Single(choice => choice.Label == "alpha");
        vault.ToggleGroupFilterCommand.Execute(alphaChoice);

        vault.HostBoardOrder.Select(row => row.Label)
            .ShouldBe(["a-prod", "a-dev"], ignoreOrder: true, "narrowed to alpha, both its hosts");

        var prodChoice = vault.TagFilterChoices.Single(choice => choice.Label == "prod");
        vault.ToggleTagFilterCommand.Execute(prodChoice);

        vault.HostBoardOrder.Select(row => row.Label)
            .ShouldBe(["a-prod"], "alpha AND prod, not either alone — b-prod fails the group half");

        vault.HostFilter = "a-dev";

        vault.HostBoardOrder.ShouldBeEmpty(
            "the find box adds a third condition none of alpha's prod-tagged hosts can pass");

        vault.HostFilter = string.Empty;
        vault.ResetTagFilterCommand.Execute(null);
        vault.ResetGroupFilterCommand.Execute(null);

        vault.HostBoardOrder.Select(row => row.Label)
            .ShouldBe(["a-prod", "a-dev", "b-prod"], ignoreOrder: true, "every filter cleared");
    }

    /// <summary>
    /// The same label always draws the same two letters and the same hue — across a rebuild, which replaces
    /// every row object outright, and that is exactly the case worth holding: nothing on the row itself
    /// survives a reload, so the answer has to come from the label alone.
    /// </summary>
    /// <remarks>
    /// FNV-1a rather than <see cref="string.GetHashCode()"/> is the reason this can be asserted at all — see
    /// <c>HostRowViewModel.MonogramHue</c>. A hash randomised per process would still pass a same-instance
    /// comparison but never a same-label-after-reload one, which is the case that matters: a card must not
    /// repaint itself a different colour because the application happened to restart.
    /// </remarks>
    [Fact]
    public async Task TheSameLabelDrawsTheSameMonogramAcrossAReload()
    {
        await AddHostAsync("prod-db-01");

        var before = Host("prod-db-01");
        var monogram = before.Monogram;
        var hue = before.MonogramHue;

        monogram.ShouldBe("pd", "the first letters of the first two hyphen-separated words, lowercased");

        await vault.LoadAsync(Token);

        var after = Host("prod-db-01");
        after.ShouldNotBeSameAs(before, "the reload replaces every row — this is the case worth holding");
        after.Monogram.ShouldBe(monogram);
        after.MonogramHue.ShouldBe(hue);
    }

    /// <remarks>
    /// The one-word rule is a different branch of <c>HostRowViewModel.Monogram</c> than the two-word one
    /// above, and worth its own case: it is the first two characters of the label rather than the first
    /// letter of two words, which for a one-word host name are not the same computation by coincidence.
    /// </remarks>
    [Fact]
    public async Task AOneWordLabelDrawsItsFirstTwoCharacters()
    {
        await AddHostAsync("Bastion");

        Host("Bastion").Monogram.ShouldBe("ba");
    }

    // ---- Helpers ----

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

    private async Task AddGroupAsync(string label)
    {
        vault.GroupEditorLabel = label;
        await vault.SaveGroupCommand.ExecuteAsync(Token);
        await vault.LoadAsync(Token);
    }

    private async Task AddHostAsync(string label)
    {
        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = label;
        vault.EditorHostname = $"{label}.internal";
        vault.EditorUsername = "deploy";

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

    private async Task AddHostInGroupAsync(string label, string groupLabel)
    {
        vault.NewHostCommand.Execute(null);
        vault.EditorLabel = label;
        vault.EditorHostname = $"{label}.internal";
        vault.EditorUsername = "deploy";
        vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
            choice => string.Equals(choice.Label, groupLabel, StringComparison.Ordinal));

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

    private async Task AddHostInGroupWithTagAsync(string label, string groupLabel, string tag)
    {
        await AddHostInGroupAsync(label, groupLabel);

        vault.SelectedHost = Host(label);
        vault.EditSelectedHostCommand.Execute(null);

        vault.EditorNewTag = tag;
        await vault.AddEditorTagCommand.ExecuteAsync(Token);

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