using Avalonia;
using Avalonia.Controls;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Import;
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>
/// v5c: whether each of settings mode's four pages fits the rectangle it is actually given, and whether the
/// rows a fidelity pass could quietly unwire — Updates on General, Windows Hello on Security — are still
/// wired to the real commands.
/// </summary>
/// <remarks>
/// <para>
/// The budget is <see cref="LayoutHarness.SettingsContentWidth"/> by <see cref="LayoutHarness.SettingsContentHeight"/>
/// — the space beside <c>SettingsNav</c>'s own 340 pixels, under settings mode's own titlebar and with no
/// status bar or update banner beneath it, at the window's minimum. Not the design's own 1100-wide column,
/// which is wider than that budget: see the <c>MaxWidth</c> remark on <see cref="LayoutHarness.SettingsDesignContentWidth"/>
/// for why the pages ask for 1100 at most rather than exactly.
/// </para>
/// <para>
/// A real unlocked vault, on the same reasoning <see cref="ScreenLayoutTests"/> gives: the Security page
/// binds <c>KnownHostsScreen.Summary</c>, which is null until a vault is open, and a stand-in vault would
/// still have to be the real <see cref="VaultViewModel"/> type for the compiled bindings to resolve at all.
/// </para>
/// </remarks>
public sealed class SettingsPagesLayoutTests : IAsyncLifetime
{
    private const string Passphrase = "a sufficiently long passphrase";
    private const string ServerUrl = "https://dodossh.example";

    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($"settings-layout-{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);

        shell = new MainWindowViewModel(
            new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-settings-layout-{Guid.CreateVersion7():N}")),
            caches,
            workspace,
            knownHosts,
            new UnavailableDeviceKeyStore(),
            static (_, _) => throw new InvalidOperationException("A layout test has no network."),
            TimeProvider.System,
            Substitute.For<ISftpSessionFactory>(),
            CheapProfile)
        {
            State = ShellState.Unlocked,
            Vault = vault,
            AccountName = "Ripley Vega",
            Email = "ripley@example.test",
            Issuer = "https://sso.example.test/realms/dodotech",
        };
    }

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

    [Fact]
    public Task TheGeneralPageFitsAtTheWindowsMinimum() =>
        MeasureAsync(() => new SettingsGeneralPage(), faults => faults.ShouldBeEmpty());

    [Fact]
    public Task ThePreferencesPageFitsAtTheWindowsMinimum() =>
        MeasureAsync(() => new SettingsPreferencesPage(), faults => faults.ShouldBeEmpty());

    [Fact]
    public Task TheAccountPageFitsAtTheWindowsMinimum() =>
        MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("the ordinary shape"));

    /// <remarks>The other shape the Account page takes — the sign-out confirmation card in place of the row.</remarks>
    [Fact]
    public async Task TheAccountPageFitsWithTheSignOutConfirmationUp()
    {
        shell.SignOutCommand.Execute(null);
        shell.IsConfirmingSignOut.ShouldBeTrue();

        await MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("with the confirm card up"));
    }

    [Fact]
    public Task TheSecurityPageFitsWhileThisMachineCanRegisterADeviceKey()
    {
        shell.CanRegisterDevice = true;
        shell.CanForgetDevice = false;

        return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to register"));
    }

    [Fact]
    public Task TheSecurityPageFitsWhileThisMachineIsAlreadyRegistered()
    {
        shell.CanRegisterDevice = false;
        shell.CanForgetDevice = true;

        return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to withdraw"));
    }

    [Fact]
    public Task TheSecurityPageFitsOnAMachineWithNowhereToKeepADeviceKey()
    {
        shell.CanRegisterDevice = false;
        shell.CanForgetDevice = false;
        shell.HasNoDeviceKeyOption.ShouldBeTrue();

        return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("the no-TPM explanation"));
    }

    /// <summary>
    /// Checking for updates on the General page is still the real <c>UpdateViewModel</c> command, not a
    /// row a fidelity pass silently detached while restyling it into the card idiom.
    /// </summary>
    [Fact]
    public Task TheGeneralPagesCheckNowButton_IsWiredToTheRealUpdatesCommand() =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var page = new SettingsGeneralPage { DataContext = shell };

                var window = LayoutHarness.HostAtMinimumSize(
                    page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);

                try
                {
                    ButtonNamed(window, "CHECK NOW").Command.ShouldBeSameAs(shell.Updates.CheckNowCommand);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>
    /// Windows Hello's two commands — moved here whole from the old Preferences screen — are reachable from
    /// the Security page in both of the states they can be in.
    /// </summary>
    [Fact]
    public async Task TheSecurityPagesHelloButtons_AreWiredToTheRealDeviceCommands()
    {
        shell.CanRegisterDevice = true;
        shell.CanForgetDevice = false;

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var page = new SettingsSecurityPage { DataContext = shell };

                var window = LayoutHarness.HostAtMinimumSize(
                    page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);

                try
                {
                    ButtonNamed(window, "REGISTER").Command.ShouldBeSameAs(shell.RegisterDeviceCommand);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

        shell.CanRegisterDevice = false;
        shell.CanForgetDevice = true;

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var page = new SettingsSecurityPage { DataContext = shell };

                var window = LayoutHarness.HostAtMinimumSize(
                    page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);

                try
                {
                    ButtonNamed(window, "STOP UNLOCKING HERE").Command.ShouldBeSameAs(shell.ForgetDeviceCommand);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    // ---- v5c-2: Vaults, Groups, Tags ----
    //
    // Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
    // LayoutHarness.ScreenWidth/ScreenHeight (the space beside the ordinary nav rail); it is a settings page
    // now, so its own layout coverage belongs here, against the narrower settings budget, beside every other
    // settings page's. Groups and Tags are new pages with nothing to migrate.

    /// <remarks>
    /// A shared vault added straight into the fixture's own session — the same technique the retired
    /// VaultsScreen layout tests used — so the card list draws both the personal vault and a shared one
    /// without a live server: <c>VaultRowViewModel.IsShared</c> only asks whether the vault carries a team
    /// id, which this sets without needing <see cref="MainWindowViewModel"/>'s own (offline) connection.
    /// </remarks>
    private async Task AddSharedVaultAsync()
    {
        using var teamServer = new StubTeamServer();

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

    [Fact]
    public async Task TheVaultsPageFitsWithTheVaultListPopulated()
    {
        await AddSharedVaultAsync();
        await shell.Vaults.LoadAsync(Token);

        await MeasureAsync(
            () => new SettingsVaultsPage(),
            faults => faults.ShouldBeEmpty("a personal vault and a shared one"));
    }

    [Fact]
    public async Task TheVaultsPageFitsWithTheNewVaultFormOpen()
    {
        await shell.Vaults.LoadAsync(Token);
        shell.Vaults.NewVaultCommand.Execute(null);

        await MeasureAsync(
            () => new SettingsVaultsPage(),
            faults => faults.ShouldBeEmpty("the new-vault form open"));
    }

    [Fact]
    public async Task TheVaultsPageFitsWithTheRenameFormOpen()
    {
        await AddSharedVaultAsync();
        await shell.Vaults.LoadAsync(Token);

        shell.Vaults.RenameVaultRowCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));

        await MeasureAsync(
            () => new SettingsVaultsPage(),
            faults => faults.ShouldBeEmpty("the rename form open"));
    }

    /// <remarks>
    /// The panel's own geometry — the ListBoxes, the SHARE KEY/WITHDRAW KEY row, the KEY HOLDERS list —
    /// with nothing in Members or Grants, since the fixture's connection is offline and both are read from
    /// the server on selection. An empty ListBox is zero pixels tall and exempt from this harness's own
    /// "no size" rule, so this is still a real check of everything around it.
    /// </remarks>
    [Fact]
    public async Task TheVaultsPageFitsWithTheMembersPanelOpen()
    {
        await AddSharedVaultAsync();
        await shell.Vaults.LoadAsync(Token);

        shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));

        await MeasureAsync(
            () => new SettingsVaultsPage(),
            faults => faults.ShouldBeEmpty("the members panel open"));
    }

    [Fact]
    public async Task TheGroupsPageFitsWithGroupsPopulated()
    {
        await AddGroupAsync("production");
        await AddGroupAsync("staging");

        await MeasureAsync(
            () => new SettingsGroupsPage(),
            faults => faults.ShouldBeEmpty("two groups and the No group footer"));
    }

    [Fact]
    public async Task TheGroupsPageFitsWithTheEditorOpen()
    {
        vault.NewGroupCommand.Execute(null);

        await MeasureAsync(
            () => new SettingsGroupsPage(),
            faults => faults.ShouldBeEmpty("the group editor open"));
    }

    [Fact]
    public async Task TheGroupsPageFitsWithTheDeleteConfirmationArmed()
    {
        await AddGroupAsync("production");

        vault.DeleteGroupCommand.Execute(vault.Groups.Single());

        await MeasureAsync(
            () => new SettingsGroupsPage(),
            faults => faults.ShouldBeEmpty("the delete confirmation armed"));
    }

    [Fact]
    public async Task TheTagsPageFitsWithTagsPopulated()
    {
        await AddTagAsync("production");
        await AddTagAsync("staging");

        await MeasureAsync(
            () => new SettingsTagsPage(),
            faults => faults.ShouldBeEmpty("two tags"));
    }

    [Fact]
    public async Task TheTagsPageFitsWithTheEditorOpen()
    {
        vault.NewTagCommand.Execute(null);

        await MeasureAsync(
            () => new SettingsTagsPage(),
            faults => faults.ShouldBeEmpty("the tag editor open"));
    }

    [Fact]
    public async Task TheTagsPageFitsWithTheDeleteConfirmationArmed()
    {
        await AddTagAsync("production");

        vault.DeleteTagRowCommand.Execute(vault.Tags.Single());

        await MeasureAsync(
            () => new SettingsTagsPage(),
            faults => faults.ShouldBeEmpty("the delete confirmation armed"));
    }

    private async Task AddGroupAsync(string label)
    {
        vault.NewGroupCommand.Execute(null);
        vault.GroupEditorLabel = label;

        await vault.SaveGroupCommand.ExecuteAsync(null);
    }

    private async Task AddTagAsync(string label)
    {
        vault.NewTagCommand.Execute(null);
        vault.TagEditorLabel = label;

        await vault.SaveTagCommand.ExecuteAsync(null);
    }

    // ---- v5c-3: Import ----
    //
    // The importer moved into settings mode's own chrome — see MainWindowViewModel.IsImportOpen and
    // design-notes/v5c-fidelity-notes.md — so its layout coverage moved here from ScreenLayoutTests, against
    // the same SettingsContentWidth/SettingsContentHeight budget every other settings page is measured
    // against, rather than the plain full-bleed one it used to get beside the ordinary nav rail.

    [Fact]
    public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
    {
        await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
    }

    /// <remarks>
    /// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
    /// block above it, and a footer carrying the sentence that says key files are not read. That sentence
    /// is the one that must not be pushed off the bottom — it is the difference between an import somebody
    /// understands and one they think is broken.
    /// </remarks>
    [Fact]
    public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
    {
        await MeasureImportAsync(
            faults => faults.ShouldBeEmpty("with a scanned list"),
            await ScannedImportAsync());
    }

    // ---- Helpers ----

    /// <remarks>
    /// The page is built by the factory rather than handed in already constructed: an Avalonia control is
    /// owned by whichever thread creates it, and every caller of this helper must build its page on the
    /// dispatcher thread <see cref="LayoutHarness.OnTheUiThreadAsync"/> switches onto, not on the test
    /// runner's own thread the factory is captured from.
    /// </remarks>
    private Task MeasureAsync(Func<UserControl> page, Action<IReadOnlyList<string>> assert) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var control = page();
                control.DataContext = shell;

                var window = LayoutHarness.HostAtMinimumSize(
                    control, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    private static Button ButtonNamed(Visual root, string label) =>
        root.GetVisualDescendants()
            .OfType<Button>()
            .First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));

    /// <summary>
    /// Lays the importer out at the budget settings mode's own content column actually gets.
    /// </summary>
    /// <remarks>
    /// Not built through <see cref="MeasureAsync"/>: every other settings page is typed to
    /// <see cref="MainWindowViewModel"/> and takes <see cref="shell"/> as its data context, where
    /// <c>ImportScreen</c> is typed to <c>ImportViewModel</c> — the same split <c>SettingsView.axaml</c>
    /// draws by handing it <c>{Binding ImportScreen}</c> rather than the shell itself.
    /// </remarks>
    private Task MeasureImportAsync(
        Action<IReadOnlyList<string>> assert,
        ImportViewModel? import = null) =>
        LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var screen = new ImportScreen
                {
                    DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
                };

                var window = LayoutHarness.HostAtMinimumSize(
                    screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);

                try
                {
                    assert(LayoutHarness.Unreachable(window));
                }
                finally
                {
                    window.Close();
                }
            },
            Token);

    /// <summary>
    /// An import view model that has scanned a real file, so the table has rows in it.
    /// </summary>
    /// <remarks>
    /// Through a temporary directory rather than by populating the rows directly, because the shape being
    /// measured is what the parser produces — an entry with two warnings under it is taller than one
    /// without, and inventing the rows would measure a layout nothing generates.
    /// </remarks>
    private async Task<ImportViewModel> ScannedImportAsync()
    {
        var directory = Path.Combine(Path.GetTempPath(), $"dodossh-settings-import-{Guid.CreateVersion7():N}");
        Directory.CreateDirectory(directory);

        try
        {
            await File.WriteAllTextAsync(
                Path.Combine(directory, "config"),
                """
                Host *
                  ServerAliveInterval 30

                Host prod-db
                  HostName database.production.internal
                  User deploy
                  Port 2222
                  IdentityFile ~/.ssh/id_ed25519

                Host bastion-eu-west-1
                  HostName bastion.eu-west-1.example.com
                  User ops
                  ProxyCommand nc %h %p
                  Compression yes
                  compression no

                Match host anything
                  User root
                """);

            var import = new ImportViewModel(vault, new SshConfigLocator(directory));

            // Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
            // table — which is the other test.
            await import.ScanCommand.ExecuteAsync(null);

            import.HasRows.ShouldBeTrue("the fixture has hosts in it");
            import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");

            return import;
        }
        finally
        {
            Directory.Delete(directory, recursive: true);
        }
    }
}
