using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Terminal.Tests;

/// <summary>
/// What the workspace waits for outside the process, and how long a session lives once it has one.
/// </summary>
/// <remarks>
/// <para>
/// The renderer gate itself is not in question. <see cref="TerminalDataPlane.SendAsync"/> drops frames when
/// nothing is attached, so a session opened before the renderer arrives loses its <c>SessionOpened</c> frame
/// and streams output at a terminal that was never created — and that it opens when a renderer does attach
/// is covered by <see cref="TerminalDataPlaneTests.TheRenderer_Attaches"/>. What is worth a test here is the
/// half that used to be missing: waiting for a renderer that never arrives has to end.
/// </para>
/// <para>
/// The session count is here for a different reason. It is shown to a user on the unlock screen, as the
/// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug:
/// it either hides a live connection or invents one.
/// </para>
/// </remarks>
public sealed class TerminalWorkspaceTests
{
    /// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
    private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);

    [Fact]
    public async Task WaitingForARendererThatNeverAttaches_GivesUp()
    {
        // The shipped failure this stands in for is a WebView2 that never initialises — no Evergreen
        // runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this
        // side they are identical and all look like the listener being up with nothing ever connecting
        // to it. Before the wait was bounded this test would have hung instead of failing.
        await using var workspace = CreateWorkspace(
            new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250));

        workspace.Start();

        await Should.ThrowAsync<TimeoutException>(async () =>
            await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task WaitingForARenderer_ObeysItsCancellationToken()
    {
        // The timeout is the backstop; the caller's token is what makes a Connect the user gave up on
        // return at once rather than sitting out the rest of the wait. The timeout here is long enough
        // that only cancellation can end this.
        await using var workspace = CreateWorkspace(
            new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5));

        workspace.Start();

        using var cancellation = new CancellationTokenSource();
        var wait = workspace.WaitForRendererAsync(cancellation.Token);

        await cancellation.CancelAsync();

        await Should.ThrowAsync<OperationCanceledException>(async () => await wait);
    }

    [Fact]
    public async Task AnOpenSessionIsReportedAsLive()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        workspace.LiveSessionCount.ShouldBe(0);

        await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        workspace.LiveSessionCount.ShouldBe(1);

        await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        workspace.LiveSessionCount.ShouldBe(2);
    }

    /// <remarks>
    /// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
    /// closes the channel by itself — the session is still in the dictionary, holding a connection that
    /// is finished — so counting entries would report a shell that exited as still running. A user
    /// deciding whether it is safe to walk away is the person that lie is told to.
    /// </remarks>
    [Fact]
    public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive()
    {
        // A shell with no output to give: its first read returns 0, which is a remote closing the
        // channel, so the pump finishes on its own with nobody asking it to.
        var connections = new FakeConnectionFactory(bytesPerShell: 0);

        await using var workspace = CreateWorkspace(connections);

        await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
    }

    [Fact]
    public async Task ClosingASessionEndsItAndDisposesItsConnection()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await workspace.CloseSessionAsync(sessionId);

        workspace.LiveSessionCount.ShouldBe(0);
        connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue();
    }

    /// <remarks>
    /// <para>
    /// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk
    /// away"; this answers "is <em>this</em> tab still connected", and a tab that went on claiming a shell
    /// it no longer has would be the same lie one row down.
    /// </para>
    /// <para>
    /// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace
    /// never issued, or has already closed, is not live — it must not throw and must not be optimistic.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task LivenessIsAnsweredPerSession()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        var first = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
        var second = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        workspace.IsSessionLive(first).ShouldBeTrue();
        workspace.IsSessionLive(second).ShouldBeTrue();
        workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id");

        await workspace.CloseSessionAsync(first);

        workspace.IsSessionLive(first).ShouldBeFalse();
        workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another");
    }

    /// <remarks>
    /// <para>
    /// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops
    /// frames for a session nothing is listening to, so a send at a dead tab succeeds exactly as loudly as a
    /// send at a live one. That is why <c>PasteAsync</c> answers rather than returning void — and why the
    /// answer is a <see cref="bool"/> and not an exception, since a tab whose remote hung up an hour ago is
    /// still on screen and still clickable.
    /// </para>
    /// <para>
    /// What the renderer does with the frame — bracketed paste, and the Enter deliberately outside it —
    /// cannot be reached from here at all. It is JavaScript inside a WebView, and it is in
    /// <c>docs/manual-checks.md</c> for that reason.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task PastingIntoADeadSessionSaysSoRatherThanDroppingIt()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        (await workspace.PasteAsync(
            sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
            .ShouldBeTrue("the session is live");

        (await workspace.PasteAsync(
            9999, "uptime", execute: false, TestContext.Current.CancellationToken))
            .ShouldBeFalse("this workspace never issued that id");

        await workspace.CloseSessionAsync(sessionId);

        (await workspace.PasteAsync(
            sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
            .ShouldBeFalse("the tab it names is gone");
    }

    /// <remarks>
    /// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
    /// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
    /// closed has a caller who already knows, and telling it would turn one close into two.
    /// </remarks>
    [Fact]
    public async Task ASessionEndingOnItsOwnIsAnnounced()
    {
        // A shell with no output to give: its first read returns 0, which is a remote closing the channel,
        // so the pump finishes with nobody asking it to.
        var connections = new FakeConnectionFactory(bytesPerShell: 0);

        await using var workspace = CreateWorkspace(connections);

        var ended = new List<uint>();
        workspace.SessionEnded += (_, e) =>
        {
            lock (ended)
            {
                ended.Add(e.SessionId);
            }
        };

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await WaitUntilAsync(() =>
        {
            lock (ended)
            {
                return ended.Contains(sessionId);
            }
        });
    }

    /// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
    [Fact]
    public async Task ClosingASessionIsNotAnnouncedBack()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        var announcements = 0;
        workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await workspace.CloseSessionAsync(sessionId);

        Volatile.Read(ref announcements)
            .ShouldBe(0, "a close the caller asked for is not news to report back to it");
    }

    /// <remarks>
    /// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
    /// that the SSH connections are known to be released rather than assumed to be.
    /// </remarks>
    [Fact]
    public async Task DisposingTheWorkspaceClosesEverySession()
    {
        var connections = new FakeConnectionFactory();

        var workspace = CreateWorkspace(connections);

        await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
        await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await workspace.DisposeAsync();

        workspace.LiveSessionCount.ShouldBe(0);
        connections.Connections.Count.ShouldBe(2);
        connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
    }

    // ---- Helpers ----

    /// <remarks>
    /// <para>
    /// Input that did not come from the keyboard. The Android head's accessory key row is what needs this —
    /// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote
    /// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a
    /// stray character in somebody's shell.
    /// </para>
    /// <para>
    /// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer
    /// down the socket, which the pump's own tests cover.
    /// </para>
    /// </remarks>
    [Fact]
    public async Task SendingInput_ReachesTheRemoteUnchanged()
    {
        var connections = new FakeConnectionFactory();

        await using var workspace = CreateWorkspace(connections);

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        // The up-arrow, as a PTY expects it. Three bytes, and all three matter.
        byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];

        await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken);

        await WaitUntilAsync(() =>
            connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true);
    }

    /// <remarks>
    /// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare
    /// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing
    /// would turn that into a crash on a keystroke that no longer matters.
    /// </remarks>
    [Fact]
    public async Task SendingInputToASessionThatIsGone_IsIgnored()
    {
        await using var workspace = CreateWorkspace(new FakeConnectionFactory());

        var sessionId = await workspace.OpenSessionAsync(
            Request(), TerminalSize.Default, TestContext.Current.CancellationToken);

        await workspace.CloseSessionAsync(sessionId);

        await Should.NotThrowAsync(async () =>
            await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
    }

    /// <param name="connections">
    /// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
    /// take a fake anyway, because reaching a real host from here would make this a network test.
    /// </param>
    /// <param name="rendererTimeout">The gate's bound, or null for the shipped default.</param>
    private static TerminalWorkspace CreateWorkspace(
        ISshConnectionFactory connections,
        TimeSpan? rendererTimeout = null) =>
        new(
            StubAssets(),
            connections,
            TimeProvider.System,
            rendererTimeout is { } timeout
                ? new TerminalWorkspaceOptions { RendererTimeout = timeout }
                : null);

    private static InMemoryTerminalAssetProvider StubAssets() =>
        new(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)
        {
            [TerminalDataPlane.PagePath] = new("text/html; charset=utf-8", "<!doctype html>"u8.ToArray()),
        });

    private static SshConnectionRequest Request() =>
        new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));

    /// <remarks>
    /// Polled rather than awaited on a task, because the point is what an observer of the property
    /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone
    /// telling it to.
    /// </remarks>
    private static async Task WaitUntilAsync(Func<bool> condition)
    {
        var deadline = TimeProvider.System.GetUtcNow() + PollTimeout;

        while (TimeProvider.System.GetUtcNow() < deadline)
        {
            if (condition())
            {
                return;
            }

            await Task.Delay(20, TestContext.Current.CancellationToken);
        }

        throw new TimeoutException($"The condition was still false after {PollTimeout}.");
    }
}
