using Avalonia.Controls;
using Avalonia.LogicalTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;

namespace DodoSSH.Client.App.Layout.Tests;

/// <summary>
/// The update banner, measured at the width the window is allowed to shrink to.
/// </summary>
/// <remarks>
/// <para>
/// This is the control in the application least protected by anything else. It is a fixed-height row with
/// two buttons on the right and a version string of unknown length in the middle, and it is <em>not</em>
/// inside a <see cref="ScrollViewer"/> — so the harness's scrolling exemption does not apply and both
/// buttons are genuinely measured. The failure it exists to catch is RESTART NOW pushed off the right edge
/// by a long version, which nobody would see until the day there was an update to install.
/// </para>
/// <para>
/// It cannot be measured through <c>MainWindow</c>, which is why the banner is its own control: WebView2's
/// adapter refuses the headless session's thread. See <c>LayoutHarnessTests.WhyTheWindowItselfIsNeverShown</c>.
/// </para>
/// </remarks>
public sealed class UpdateBannerTests
{
    private static CancellationToken Token => TestContext.Current.CancellationToken;

    /// <summary>
    /// A channel that reports an update is ready, so the banner has something to draw.
    /// </summary>
    /// <remarks>
    /// Local to this suite rather than shared with <c>DodoSSH.Client.App.Tests</c>: what a layout test needs
    /// is a view model in a given state, and threading the other suite's fake through a project reference
    /// would couple two suites that otherwise share nothing.
    /// </remarks>
    private sealed class ReadyChannel : IUpdateChannel
    {
        public bool IsSupported => true;

        // The desktop's answer, which is what this suite lays out. Nothing here applies anything.
        public bool ApplyingEndsTheProcess => true;

        public string CurrentVersion => "1.0.0";

        public Task<AvailableUpdate?> CheckAsync(CancellationToken cancellationToken) =>
            Task.FromResult<AvailableUpdate?>(new AvailableUpdate(LongVersion));

        public Task DownloadAsync(
            AvailableUpdate update,
            IProgress<int> progress,
            CancellationToken cancellationToken)
        {
            progress.Report(100);

            return Task.CompletedTask;
        }

        public void ApplyAndRestart(AvailableUpdate update)
        {
        }
    }

    /// <summary>
    /// A version longer than anything this project should ever tag, which is the point.
    /// </summary>
    /// <remarks>
    /// MinVer produces exactly this shape on an untagged commit — <c>0.0.0-alpha.0.114</c> — and a release
    /// candidate would produce something close to it. A banner that only fits <c>1.2.3</c> would look fine
    /// in every screenshot and break on the first prerelease anybody actually shipped.
    /// </remarks>
    private const string LongVersion = "10.20.30-release-candidate.4";

    private static async Task<UpdateViewModel> ReadyViewModelAsync()
    {
        var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-{Guid.CreateVersion7():N}");

        var updates = new UpdateViewModel(
            new ReadyChannel(),
            new ClientSettingsStore(new ClientPaths(directory)),
            TimeProvider.System,
            () => 3,
            restart: _ => Task.CompletedTask);

        await updates.CheckOnceAsync(TestContext.Current.CancellationToken);

        if (Directory.Exists(directory))
        {
            Directory.Delete(directory, recursive: true);
        }

        return updates;
    }

    [Fact]
    public async Task TheUpdateBannerFitsAtTheWindowsMinimumWidth()
    {
        var updates = await ReadyViewModelAsync();

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var banner = new UpdateBanner { DataContext = updates };

                var window = LayoutHarness.HostAtMinimumSize(
                    banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);

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

    /// <remarks>
    /// Held to the number <see cref="LayoutHarness.UpdateBannerHeight"/> states, the same way the titlebar,
    /// tab strip and status bar are. A banner that grew with its contents would shrink the terminal by an
    /// amount nothing predicts, and would do it while somebody is reading it.
    /// </remarks>
    [Fact]
    public async Task TheUpdateBannerIsTheHeightItSaysItIs()
    {
        var updates = await ReadyViewModelAsync();

        await LayoutHarness.OnTheUiThreadAsync(
            () =>
            {
                var banner = new UpdateBanner { DataContext = updates };

                var window = LayoutHarness.HostAtMinimumSize(
                    banner, LayoutHarness.MinimumWidth, LayoutHarness.UpdateBannerHeight);

                try
                {
                    banner.Bounds.Height.ShouldBe(LayoutHarness.UpdateBannerHeight);
                }
                finally
                {
                    window.Close();
                }
            },
            Token);
    }

    /// <summary>
    /// The banner in the window that shows it, rather than in a host window this suite built.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The two tests above hand the control an <see cref="UpdateViewModel"/> themselves — which is precisely
    /// what the window did not do. The banner is the one control here typed to a screen's view model instead
    /// of to the shell's, and it was dropped into <c>MainWindow</c> with no data context at all, so it
    /// inherited the shell's: every compiled binding inside it then resolved against the wrong type and
    /// failed silently. The strip appeared, with no headline and both <c>Command</c>s null, and hovered and
    /// depressed like a working banner while neither button did anything.
    /// </para>
    /// <para>
    /// Nothing else could have caught it. A layout test supplies the context it is measuring, and the shell
    /// suite has no visual tree — its own project file says as much: it does not cover whether the XAML binds
    /// to the right names.
    /// </para>
    /// <para>
    /// The window is constructed and never shown, for the reason
    /// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> gives, and it does not need to be: a
    /// data context propagates and a binding resolves when the context is set, not when the tree is measured.
    /// So this asserts the wiring and leaves every question of size to the two tests above.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task TheWindowHandsTheBannerTheUpdateViewModel()
    {
        var directory = Path.Combine(Path.GetTempPath(), $"dodossh-banner-shell-{Guid.CreateVersion7():N}");

        using var caches = ClientCacheFactory.ForMemory($"banner-{Guid.CreateVersion7():N}");

        await using var workspace = new TerminalWorkspace(
            new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
            Substitute.For<ISshConnectionFactory>(),
            TimeProvider.System);

        // The real shell view model, because the thing under test is what the window hands the banner. No
        // vault and no sign-in: the banner is drawn outside the unlocked half of the window on purpose.
        await using var shell = new MainWindowViewModel(
            new ClientPaths(directory),
            caches,
            workspace,
            new VaultKnownHostStore(),
            Substitute.For<IDeviceKeyStore>(),
            (_, _) => throw new NotSupportedException("nothing here signs in"),
            TimeProvider.System,
            Substitute.For<ISftpSessionFactory>(),
            updates: new ReadyChannel());

        try
        {
            await shell.Updates.CheckOnceAsync(Token);

            shell.Updates.IsBannerShowing.ShouldBeTrue();

            await LayoutHarness.OnTheUiThreadAsync(() => AssertTheBannerIsWiredTo(shell), Token);
        }
        finally
        {
            if (Directory.Exists(directory))
            {
                Directory.Delete(directory, recursive: true);
            }
        }
    }

    private static void AssertTheBannerIsWiredTo(MainWindowViewModel shell)
    {
        var window = new MainWindow { DataContext = shell };

        try
        {
            var banner = window.GetLogicalDescendants().OfType<UpdateBanner>().ShouldHaveSingleItem();

            banner.DataContext.ShouldBeSameAs(shell.Updates);
            banner.IsVisible.ShouldBeTrue();

            // The defect itself: a null command is a button that answers a click by doing nothing, and it
            // is indistinguishable from a working one until somebody presses it.
            var restart = banner.FindControl<Button>("RestartNowButton").ShouldNotBeNull();

            restart.Command.ShouldNotBeNull();
        }
        finally
        {
            window.Close();
        }
    }
}
