using System.Globalization;
using System.Text;
using DodoSSH.Client.Ssh;

namespace DodoSSH.Client.Terminal;

/// <summary>Tuning for the workspace.</summary>
public sealed class TerminalWorkspaceOptions
{
    /// <summary>
    /// How long <see cref="TerminalWorkspace.WaitForRendererAsync"/> waits for the renderer's socket
    /// before giving up.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The value has to separate two cases. Attaching is normally near-instant: WebView2 starts with the
    /// window and the page has usually attached while the user was still typing a passphrase. But a first
    /// run on a cold profile creates a user-data directory and starts a process tree of some thirty-five
    /// processes, and on a slow or loaded machine that is seconds rather than milliseconds. A renderer
    /// that will never attach — no Evergreen runtime, an install blocked by policy, an AppContainer that
    /// cannot reach loopback — will not attach however long the wait is.
    /// </para>
    /// <para>
    /// So being generous costs only how long a genuinely broken WebView2 takes to say so, while being
    /// tight costs telling someone their runtime is broken when it was merely slow. Fifteen seconds is
    /// well clear of any cold start observed here and is still an answer rather than a hang.
    /// </para>
    /// </remarks>
    public TimeSpan RendererTimeout { get; init; } = TimeSpan.FromSeconds(15);
}

/// <summary>A session whose shell has ended.</summary>
/// <param name="sessionId">The session, as the renderer and the workspace know it.</param>
public sealed class TerminalSessionEndedEventArgs(uint sessionId) : EventArgs
{
    /// <summary>The session that ended.</summary>
    public uint SessionId { get; } = sessionId;
}

/// <summary>
/// The negotiated cipher and host-key algorithm for one live session.
/// </summary>
/// <param name="Cipher">The server-to-client encryption algorithm; see <see cref="ISshConnection.Cipher"/>.</param>
/// <param name="HostKeyAlgorithm">The host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
/// <remarks>
/// Two facts rather than the whole <see cref="ISshConnection"/>, because that is all a caller outside this
/// assembly has any business reading off a session it does not own — everything else on the connection
/// (disposal, the shell) belongs to the workspace alone. See <see cref="TerminalWorkspace.GetSessionFacts"/>.
/// </remarks>
public sealed record SessionFacts(string Cipher, string HostKeyAlgorithm);

/// <summary>The renderer asking for a different font size.</summary>
/// <param name="step">
/// How far to move, in points of font size, or zero to go back to the default. It is a step rather than a
/// size because the page does not hold the current one — the host does, and clamping a step is what stops
/// two chords in flight from disagreeing about where they started.
/// </param>
public sealed class TerminalFontSizeStepEventArgs(int step) : EventArgs
{
    /// <summary>The requested move, or zero for "back to the default".</summary>
    public int Step { get; } = step;
}

/// <summary>
/// Owns the loopback data plane and every live terminal session.
/// </summary>
/// <remarks>
/// <para>
/// One data plane and one renderer page for the whole application, with a session id per terminal.
/// Not one WebView per tab: each WebView2 is a separate browser process, so twenty tabs would mean
/// twenty renderer processes and several hundred megabytes for a working set a user would call
/// ordinary. Splits and tabs are layout inside the single page.
/// </para>
/// <para>
/// <b>A session's lifetime is the application's, not the vault's.</b> This object is composed once at
/// startup and outlives every lock, deliberately: locking the vault zeroes keys, and a shell needs no
/// vault key to keep running, so a job started before the lock keeps running through it. That is a
/// policy rather than an oversight — <c>MainWindowViewModel.LockAsync</c> says why, and the shell shows
/// <see cref="LiveSessionCount"/> on the unlock screen so it is not a hidden state.
/// </para>
/// </remarks>
public sealed class TerminalWorkspace : IAsyncDisposable
{
    private readonly TerminalDataPlane dataPlane;
    private readonly ISshConnectionFactory connections;
    private readonly TimeProvider clock;
    private readonly TerminalWorkspaceOptions options;
    private readonly Dictionary<uint, LiveSession> sessions = [];

    /// <summary>
    /// Guards <see cref="sessions"/> and <see cref="nextSessionId"/>.
    /// </summary>
    /// <remarks>
    /// This dictionary is genuinely touched from more than one thread, which the tab strip made true rather
    /// than merely arguable: sessions are opened and closed from the UI thread, and
    /// <see cref="RunSessionAsync"/>'s finally now asks whether the session is still known — on whichever
    /// thread-pool thread the pump happened to unwind on. An unsynchronised <see cref="Dictionary{TKey,
    /// TValue}"/> read concurrent with a write does not merely return a stale answer; it can throw or spin.
    /// The data plane guards its own registration table the same way and for the same reason.
    /// </remarks>
    private readonly Lock sessionGate = new();
    private readonly CancellationTokenSource lifetime = new();

    /// <summary>
    /// The payload that marks a <see cref="TerminalServerOpcode.SessionOpened"/> frame as a replay rather
    /// than a fresh open. A one-byte non-empty payload, so terminal.js's existing length check (empty
    /// payload for a real open) tells the two apart without a second opcode.
    /// </summary>
    private static readonly byte[] ReplayMarker = [1];

    private uint nextSessionId = 1;
    private Task? server;
    private int disposed;

    /// <param name="assets">Where the renderer's files come from.</param>
    /// <param name="connections">How SSH connections are made.</param>
    /// <param name="clock">Time source, so the pumps' flush interval is testable.</param>
    /// <param name="options">Tuning, or null for the defaults.</param>
    public TerminalWorkspace(
        ITerminalAssetProvider assets,
        ISshConnectionFactory connections,
        TimeProvider clock,
        TerminalWorkspaceOptions? options = null)
    {
        this.connections = connections;
        this.clock = clock;
        this.options = options ?? new TerminalWorkspaceOptions();

        dataPlane = new TerminalDataPlane(assets);

        // Forwarded rather than re-raised with the workspace as the sender, so a handler can tell where it
        // came from. Nothing here decides anything about the size: the shell owns it, because the shell is
        // what remembers it between launches.
        dataPlane.FontSizeStepRequested += (_, e) => FontSizeStepRequested?.Invoke(this, e);

        // Fire-and-forget: this fires on the socket-accept thread, in the middle of the data plane's own
        // handshake handling, and has no business making that wait on however long a replay takes. See
        // ReplayAfterAttachAsync for what "replay" means and why racing the fresh page's own first frames
        // is harmless.
        dataPlane.SocketAttached += (_, _) => _ = ReplayAfterAttachAsync();
    }

    /// <summary>
    /// Where connections are recorded, or null to record none.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Settable rather than a constructor parameter, because the two objects have different lifetimes and
    /// the workspace's is the longer one: it is composed at startup and outlives every lock, while anything
    /// that can write to a vault exists only while one is open. Locking sets this back to null, and the
    /// sessions that were already running go on running with nothing recording them.
    /// </para>
    /// <para>
    /// Which means an entry can be missed — a shell open across a lock and closed after it. The recorder
    /// holds its store for close-out past <c>Close()</c> precisely so that the common case does not, and the
    /// residue is stated here rather than papered over.
    /// </para>
    /// </remarks>
    public IConnectionLogSink? ConnectionLog { get; set; }

    /// <summary>Where the WebView should navigate.</summary>
    public Uri PageUrl => dataPlane.PageUrl;

    /// <summary>
    /// How many terminals still have a live shell behind them.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
    /// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
    /// a session whose shell exited half an hour ago is still in the dictionary. A completed
    /// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
    /// and it has already sent <c>SessionClosed</c> to the renderer.
    /// </para>
    /// <para>
    /// This exists because the shell shows the number on the unlock screen, and a lock screen that
    /// claims a shell is still running when it is not would be the same class of dishonesty the number
    /// is there to prevent.
    /// </para>
    /// </remarks>
    public int LiveSessionCount
    {
        get
        {
            lock (sessionGate)
            {
                return sessions.Values.Count(session => !session.Run.IsCompleted);
            }
        }
    }

    /// <summary>
    /// Whether one session's shell is still running.
    /// </summary>
    /// <remarks>
    /// The same question <see cref="LiveSessionCount"/> answers in aggregate, and answered the same way: a
    /// completed <c>Run</c> is what "the shell is gone" looks like, because nothing removes the entry when
    /// the remote closes the channel on its own. A session id this workspace never issued is not live.
    /// </remarks>
    public bool IsSessionLive(uint sessionId)
    {
        lock (sessionGate)
        {
            return sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted;
        }
    }

    /// <summary>
    /// The negotiated cipher and host-key algorithm for one live session, or null when the id names no
    /// session this workspace still has open.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Additive, and deliberately narrow: the caller this exists for — the shell's own connect path, which
    /// wants these two facts for its status bar — has no other business with a session it does not own, and a
    /// method that handed back the <see cref="ISshConnection"/> itself would have handed over the shell,
    /// disposal and all, to code that already goes through <see cref="CloseSessionAsync"/> for that.
    /// </para>
    /// <para>
    /// "No session this workspace still has open" covers two different absences the same way
    /// <see cref="IsSessionLive"/> already does: an id this workspace never issued, and one whose shell has
    /// already ended but whose entry has not been removed yet. Both are "nothing to report" to a caller
    /// asking what a session's transport looks like right now.
    /// </para>
    /// </remarks>
    public SessionFacts? GetSessionFacts(uint sessionId)
    {
        lock (sessionGate)
        {
            if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
            {
                return null;
            }

            return new SessionFacts(session.Connection.Cipher, session.Connection.HostKey.Algorithm);
        }
    }

    /// <summary>
    /// A live session's flow-control window, or null when the id names no session this workspace still has
    /// open.
    /// </summary>
    /// <remarks>
    /// A test seam rather than something the shell has ever needed: nothing outside this assembly has a
    /// reason to see a pump's credit window rather than what the transport does with it, but
    /// <see cref="ReplayAfterAttachAsync"/>'s reset of that window on reattach is exactly the kind of thing
    /// that is easy to get backwards, and worth asserting directly rather than only through its side
    /// effects. Internal rather than public, reachable from the test assembly through the
    /// <c>InternalsVisibleTo</c> this project already declares for it.
    /// </remarks>
    internal CreditWindow? CreditsFor(uint sessionId)
    {
        lock (sessionGate)
        {
            return sessions.TryGetValue(sessionId, out var session) ? session.Pump.Credits : null;
        }
    }

    /// <summary>
    /// Raised with the session id once a session is over — its shell having ended on its own, or a
    /// deliberate close having fully drained.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A tab has to be able to stop claiming it is connected, and polling would be the alternative: a timer
    /// asking <see cref="IsSessionLive"/> often enough to look immediate, for a thing that happens a handful
    /// of times a day. The renderer already learns about this instantly — the pump sends
    /// <c>SessionClosed</c> and the page writes the reason into the pane — so this is the same fact reaching
    /// the half of the interface Avalonia draws.
    /// </para>
    /// <para>
    /// <b>Raised only after the session's run task has completed, and that ordering is load-bearing.</b>
    /// It used to fire from inside the run's own finally block, where the task is by definition not yet
    /// complete — so a handler reading <see cref="LiveSessionCount"/> still counted the session that had
    /// just ended, which is how the phone's foreground notification went on saying "1 shell connected"
    /// over nothing. See <see cref="AnnounceEndedAsync"/>. Raised on a thread-pool continuation, or on the
    /// closer's own thread; a handler that touches an observable collection has to marshal either way.
    /// </para>
    /// <para>
    /// <b>Raised by <see cref="CloseSessionAsync"/> too, which reverses a recorded decision.</b> The old
    /// reasoning — the caller asked, so telling it is an echo — assumed every subscriber was the caller.
    /// The phone's keep-alive is not: it hears this event to reconcile a notification with reality, and a
    /// close that announced nothing left that notification claiming a shell that was gone. Every subscriber
    /// treats the event as "reconcile" rather than "act" — a tab is marked dead if it is still there and
    /// skipped if it is not — so a second announcement for a session that already announced its own end
    /// (closing the tab of a shell that exited earlier) is deliberate and harmless. Shutdown is the one
    /// close that stays silent: <see cref="DisposeAsync"/> is tearing the subscribers down with the
    /// sessions, and news nobody is left to hear is not news.
    /// </para>
    /// </remarks>
    public event EventHandler<TerminalSessionEndedEventArgs>? SessionEnded;

    /// <summary>Raised when the renderer's own keyboard asks for a different font size.</summary>
    /// <remarks>
    /// The chords can only be heard by the page — once a terminal has focus the host's window sees no key
    /// events at all — so this is how Ctrl+plus reaches the thing that owns the setting. Raised on the
    /// socket's receive loop; marshal before touching a view model.
    /// </remarks>
    public event EventHandler<TerminalFontSizeStepEventArgs>? FontSizeStepRequested;

    /// <summary>
    /// Raised once a (re)attached renderer has been sent everything this workspace owns for it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The workspace's own share of "put the page back the way it was" is the sessions — each live one gets
    /// its <c>SessionOpened</c> frame again, done by the time this fires. What is left is what the workspace
    /// has no business owning: the font size and which tab is selected are both remembered by the shell, not
    /// by a terminal, so this is the seam the shell uses to re-push them. See
    /// <c>MainWindowViewModel</c>'s subscription for the other half.
    /// </para>
    /// <para>
    /// Raised on the socket-accept thread, same as <see cref="TerminalDataPlane.SocketAttached"/> that
    /// triggers it — a handler that touches a view model has to marshal.
    /// </para>
    /// </remarks>
    public event EventHandler? RendererReattached;

    /// <summary>Starts the loopback listener.</summary>
    public void Start() => server = dataPlane.RunAsync(lifetime.Token);

    /// <summary>
    /// Tells every pane what size to draw at.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Sent to the page rather than applied per session, and unconditionally rather than only when a
    /// session is live: the page keeps the size for panes opened later, so this is also how the first
    /// terminal of a launch comes up at the size the user last chose.
    /// </para>
    /// <para>
    /// Every live pane refits as a result and reports its new geometry, so the remotes are told they have
    /// fewer columns. That round trip is the feature rather than a side effect — see
    /// <see cref="TerminalServerOpcode.FontSize"/>.
    /// </para>
    /// </remarks>
    /// <param name="pixels">The size in CSS pixels. Clamped by the caller; sent as one byte.</param>
    /// <param name="cancellationToken">Cancellation.</param>
    public ValueTask SetFontSizeAsync(int pixels, CancellationToken cancellationToken) =>
        dataPlane.SendAsync(
            TerminalFrame.Create(
                (byte)TerminalServerOpcode.FontSize,
                sessionId: 0,
                TerminalFrame.CreateFontSizePayload(pixels)),
            cancellationToken);

    /// <summary>
    /// Waits until the renderer page has attached its socket.
    /// </summary>
    /// <remarks>
    /// <para>
    /// A session opened before the renderer attaches would have its <c>SessionOpened</c> frame dropped —
    /// the transport discards frames when nothing is connected — leaving output arriving for a terminal
    /// that was never created. The gate is the invariant and stays.
    /// </para>
    /// <para>
    /// Bounded, because whether the renderer attaches at all depends on a WebView2 runtime this process
    /// does not install. An unbounded wait turned a missing runtime into a Connect that never returned,
    /// with the caller's busy state never cleared and nothing on screen to explain it. Callers are
    /// expected to translate the timeout into something that names the runtime, because
    /// <see cref="TimeoutException"/>'s own message names nothing.
    /// </para>
    /// </remarks>
    /// <param name="cancellationToken">Abandons the wait.</param>
    /// <exception cref="TimeoutException">
    /// No renderer attached within <see cref="TerminalWorkspaceOptions.RendererTimeout"/>.
    /// </exception>
    public Task WaitForRendererAsync(CancellationToken cancellationToken) =>
        dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);

    /// <summary>Connects to a host and starts a terminal for it.</summary>
    /// <param name="request">What to connect to, as whom, and with what.</param>
    /// <param name="size">The pseudo-terminal's initial size.</param>
    /// <param name="progress">
    /// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread;
    /// see <see cref="SshConnectionPhase"/>. Optional because a session opened by anything other than the
    /// connecting card has nobody watching a step list for it, which is every caller but one.
    /// </param>
    /// <param name="cancellationToken">Abandons the attempt.</param>
    /// <returns>The session id, which identifies this terminal in the renderer.</returns>
    /// <remarks>
    /// <see cref="SshConnectionPhase.OpeningShell"/> is reported here rather than by the factory because
    /// this is where it happens: the factory's work ends with an authenticated connection, and asking for a
    /// pseudo-terminal on it is a separate round trip this method makes.
    /// </remarks>
    public async Task<uint> OpenSessionAsync(
        SshConnectionRequest request,
        TerminalSize size,
        IProgress<SshConnectionPhase>? progress,
        CancellationToken cancellationToken)
    {
        var connection = await connections
            .ConnectAsync(request, progress, cancellationToken)
            .ConfigureAwait(false);

        progress?.Report(SshConnectionPhase.OpeningShell);

        ISshShellSession shell;
        try
        {
            shell = await connection.OpenShellAsync(size, cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            await connection.DisposeAsync().ConfigureAwait(false);
            throw;
        }

        uint sessionId;
        TerminalSessionPump pump;

        lock (sessionGate)
        {
            sessionId = nextSessionId++;
            pump = new TerminalSessionPump(sessionId, shell, dataPlane, clock);
        }

        dataPlane.Register(sessionId, pump);

        // After the connection succeeded and before the run begins. Ordered that way for two reasons: a
        // host-key refusal throws out of ConnectAsync above and must never be recorded as a session that
        // started, and a session whose shell ends immediately must already have a ticket open for the
        // finally below to close.
        ConnectionLog?.Opened(sessionId, Describe(request), clock.GetUtcNow());

        // Registered before running, so an acknowledgement that arrives with the very first output
        // frame has somewhere to go.
        var run = RunSessionAsync(sessionId, pump);

        lock (sessionGate)
        {
            sessions[sessionId] = new LiveSession(connection, pump, run);
        }

        // The announcement's own continuation — see AnnounceEndedAsync. Started after the entry is stored,
        // so the containment check inside it can never run against a dictionary the session had not reached.
        _ = AnnounceEndedAsync(sessionId, run);

        return sessionId;
    }

    /// <summary>
    /// Shows one terminal's pane and hides the others.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The page keeps a pane per session and displays one of them, so switching tabs is this frame and
    /// nothing else — no terminal is destroyed, no scrollback is lost, and the shell behind a hidden pane
    /// goes on running and goes on being read. That is the whole reason tabs cost so little here: the
    /// expensive object is the WebView, and there is one of those however many tabs are open.
    /// </para>
    /// <para>
    /// Sent for a session id this workspace does not know as readily as for one it does. The page ignores
    /// a pane it has never created, and refusing here would mean holding a second copy of the tab strip's
    /// idea of what exists — which is the sort of duplicated truth that ends up disagreeing.
    /// </para>
    /// </remarks>
    public ValueTask ActivateSessionAsync(uint sessionId, CancellationToken cancellationToken) =>
        dataPlane.SendAsync(
            TerminalFrame.Create((byte)TerminalServerOpcode.SessionActivated, sessionId, []),
            cancellationToken);

    /// <summary>
    /// Inserts text into one terminal, as if it had been pasted there.
    /// </summary>
    /// <param name="sessionId">The terminal to insert into.</param>
    /// <param name="text">What to insert. Sent verbatim.</param>
    /// <param name="execute">Whether to press Enter afterwards.</param>
    /// <param name="cancellationToken">Cancellation.</param>
    /// <returns>
    /// <see langword="false"/> when that session's shell is not running, which is an ordinary answer rather
    /// than an error: a tab whose remote hung up an hour ago is still on screen and still selectable, and
    /// somebody clicking a snippet at it has made a mistake worth a sentence, not an exception.
    /// </returns>
    /// <remarks>
    /// <para>
    /// <b>Refused for a dead session rather than sent and dropped.</b> The transport discards frames for a
    /// pane the page no longer has, so sending regardless would look exactly like success — and the one thing
    /// somebody inserting a command needs to know is whether it arrived.
    /// </para>
    /// <para>
    /// The liveness check and the send are deliberately not atomic. A shell that ends between the two is a
    /// race no lock can close — the remote could hang up while the frame is in the socket — so the check is
    /// there to catch the ordinary case honestly, not to make a guarantee it cannot keep.
    /// </para>
    /// </remarks>
    public async Task<bool> PasteAsync(
        uint sessionId,
        string text,
        bool execute,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(text);

        if (!IsSessionLive(sessionId))
        {
            return false;
        }

        var utf8 = Encoding.UTF8.GetBytes(text);
        var payload = new byte[1 + utf8.Length];

        payload[0] = execute ? (byte)1 : (byte)0;
        utf8.CopyTo(payload, 1);

        await dataPlane
            .SendAsync(
                TerminalFrame.Create((byte)TerminalServerOpcode.Paste, sessionId, payload),
                cancellationToken)
            .ConfigureAwait(false);

        return true;
    }

    /// <summary>
    /// Sends keystrokes to one terminal as though they had been typed into it.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Ordinary typing does not come through here — it goes from the renderer's own keyboard handling
    /// straight down the socket, which is one hop shorter and is what keeps a fast <c>cat</c> responsive.
    /// This is for input that has no key on the keyboard to produce it.
    /// </para>
    /// <para>
    /// <b>Which on a phone is most of the useful input.</b> A software keyboard has no Ctrl, Esc, Tab or
    /// arrows, so the Android head draws an accessory row and sends the bytes itself; the desktop head
    /// will want the same seam the day it grows a snippet that types into a terminal. Bytes rather than a
    /// key name deliberately: what a terminal wants is a control sequence, and translating one at this
    /// layer would mean owning a keymap that the renderer already owns.
    /// </para>
    /// <para>
    /// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
    /// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
    /// </para>
    /// </remarks>
    /// <param name="sessionId">The terminal to type into.</param>
    /// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    public ValueTask SendInputAsync(
        uint sessionId,
        ReadOnlyMemory<byte> data,
        CancellationToken cancellationToken)
    {
        TerminalSessionPump? pump;

        lock (sessionGate)
        {
            pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
        }

        return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
    }

    /// <summary>
    /// Closes one terminal.
    /// </summary>
    /// <remarks>
    /// Closing one terminal without taking the process down is what a tab close needs, and what it is now
    /// reached for. It is also what <see cref="DisposeAsync"/> uses on the way out, which was its only
    /// caller while the interface had no tabs.
    /// </remarks>
    public async Task CloseSessionAsync(uint sessionId)
    {
        LiveSession? session;

        lock (sessionGate)
        {
            if (!sessions.Remove(sessionId, out session))
            {
                return;
            }
        }

        dataPlane.Unregister(sessionId);

        // Before the teardown, while the socket is still up. The page holds an xterm instance, its whole
        // scrollback and a WebGL context per pane and never reclaimed one on its own; a frame dropped here
        // is a leak nothing else would notice, because it is inside the WebView.
        await dataPlane
            .SendAsync(
                TerminalFrame.Create((byte)TerminalServerOpcode.SessionRemoved, sessionId, []),
                CancellationToken.None)
            .ConfigureAwait(false);

        // The connection goes even if the pump's disposal throws. The entry is already out of the dictionary,
        // so nothing will come back for it — not DisposeAsync's loop, which snapshots the keys — and an
        // undisposed ISshConnection is a live authenticated channel to a remote host that this process has
        // forgotten about. Which of the two is more important is not close.
        try
        {
            await session.Pump.DisposeAsync().ConfigureAwait(false);
        }
        finally
        {
            await session.Connection.DisposeAsync().ConfigureAwait(false);
        }

        // Draining, not doing work. This await exists so the pump's loops have finished before the caller
        // moves on; the session is already dead either way, and a fault here is nothing the caller can act
        // on — the pump's last act is a best-effort SessionClosed frame on a socket that may have gone with
        // the window. Letting that escape would fault a tab-close command over a session that closed fine.
        try
        {
            await session.Run.ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Expected on the ordinary path: disposing the pump cancels its run.
        }

        // After the drain, so a handler reading LiveSessionCount sees this session already gone — the
        // event's own remark carries why a deliberate close is announced at all, and why shutdown is not:
        // DisposeAsync sets the flag before its closing loop, and is dismantling every subscriber anyway.
        if (Volatile.Read(ref disposed) == 0)
        {
            SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
        }
    }

    /// <inheritdoc />
    public async ValueTask DisposeAsync()
    {
        if (Interlocked.Exchange(ref disposed, 1) == 1)
        {
            return;
        }

        uint[] open;

        lock (sessionGate)
        {
            open = [.. sessions.Keys];
        }

        foreach (var sessionId in open)
        {
            await CloseSessionAsync(sessionId).ConfigureAwait(false);
        }

        await lifetime.CancelAsync().ConfigureAwait(false);
        await dataPlane.DisposeAsync().ConfigureAwait(false);

        if (server is not null)
        {
            try
            {
                await server.ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                // Expected: the accept loop is stopped by cancelling it.
            }
        }

        lifetime.Dispose();
    }

    /// <summary>
    /// Rebuilds a freshly (re)attached page's idea of what is running, then tells the shell to rebuild its
    /// own.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Runs on the socket-accept thread that raised <see cref="TerminalDataPlane.SocketAttached"/> — the
    /// constructor wires it up fire-and-forget for exactly that reason, so this method owns its own error
    /// handling rather than leaving an unobserved exception for nobody to see.
    /// </para>
    /// <para>
    /// Every live session — one whose <c>Run</c> has not completed — gets two things. Its credit window is
    /// reset, because whatever was outstanding was reserved against bytes sent to a page that is now gone;
    /// the acknowledgement that would return that credit died with it, and without this reset the session
    /// would stall the moment 256 KiB of history had accumulated. And it gets its <c>SessionOpened</c> frame
    /// again, marked with <see cref="ReplayMarker"/> so the page can tell a reattach from a session that is
    /// genuinely new — the same frame a page that survived the socket drop already has a pane for, and one a
    /// reloaded page does not.
    /// </para>
    /// <para>
    /// A session whose shell has already ended gets nothing here. Its scrollback lived only in the page that
    /// is gone, and sending a frame that implied otherwise would be exactly the kind of dishonesty this
    /// fix is supposed to remove, not add. The tab strip still shows that session ended; nothing about this
    /// method changes what <see cref="LiveSessionCount"/> or <see cref="IsSessionLive"/> report.
    /// </para>
    /// <para>
    /// This can race the fresh page's own first frames — an early resize, an acknowledgement for output it
    /// already had. That is harmless: every frame in both directions names its session, delivery order
    /// within a session is preserved by both xterm and the socket, and a frame for a pane the page has not
    /// created yet is simply dropped, the same as any frame for a session it does not know — see
    /// <c>terminal.js</c>'s <c>handleFrame</c>.
    /// </para>
    /// </remarks>
    private async Task ReplayAfterAttachAsync()
    {
        KeyValuePair<uint, LiveSession>[] live;

        lock (sessionGate)
        {
            live = [.. sessions.Where(entry => !entry.Value.Run.IsCompleted)];
        }

        try
        {
            foreach (var (sessionId, session) in live)
            {
                session.Pump.Credits.Reset();

                await dataPlane
                    .SendAsync(
                        TerminalFrame.Create((byte)TerminalServerOpcode.SessionOpened, sessionId, ReplayMarker),
                        CancellationToken.None)
                    .ConfigureAwait(false);
            }

            RendererReattached?.Invoke(this, EventArgs.Empty);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // Best-effort, same as every other fire-and-forget path here: a page that dies again mid-replay
            // leaves nothing worse than the problem this method exists to fix, and there is no caller on
            // this thread left to hand a failure to.
        }
    }

    private async Task RunSessionAsync(uint sessionId, TerminalSessionPump pump)
    {
        try
        {
            await pump.RunAsync(lifetime.Token).ConfigureAwait(false);
        }
        finally
        {
            dataPlane.Unregister(sessionId);

            // Unconditional, and this one hook covers all three ways a session ends: the user closing the
            // tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
            // the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
            // must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
            // the application is closing.
            //
            // SessionEnded is deliberately NOT raised from here, and it used to be — see
            // AnnounceEndedAsync for what was wrong with that.
            ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
        }
    }

    /// <summary>Announces a session's end once its run task has actually completed.</summary>
    /// <remarks>
    /// <para>
    /// A continuation rather than a line in <see cref="RunSessionAsync"/>'s finally, and the difference is
    /// what a handler sees. Inside that finally the run task is not yet complete — a finally is part of the
    /// task — so <see cref="LiveSessionCount"/>, which counts incomplete runs, still included the session
    /// that had just ended. The phone's keep-alive answers this event by reading exactly that count, and
    /// reconciled its foreground notification to "1 shell connected" over a shell that was gone, with
    /// nothing left to fire afterwards and correct it. By the time an await on the run resumes, the task is
    /// complete and the count is honest.
    /// </para>
    /// <para>
    /// The containment check keeps the deliberate paths out of this route: <see cref="CloseSessionAsync"/>
    /// removes the entry before it disposes the pump, and makes its own announcement after its own drain.
    /// </para>
    /// </remarks>
    private async Task AnnounceEndedAsync(uint sessionId, Task run)
    {
        try
        {
            await run.ConfigureAwait(false);
        }
        catch (Exception exception) when (exception is not OutOfMemoryException)
        {
            // The run's faults belong to whoever drains it — CloseSessionAsync, on the deliberate path.
            // This continuation cares only that the run is over, however it got there.
        }

        bool announce;

        lock (sessionGate)
        {
            announce = sessions.ContainsKey(sessionId);
        }

        if (announce)
        {
            SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId));
        }
    }

    /// <summary>The address as dialled, for the log.</summary>
    /// <remarks>
    /// The username is in here because it is part of the address that was dialled, and an address without one
    /// does not identify the connection — two people reaching one machine as different accounts is the
    /// ordinary case. This is <em>not</em> the same as recording which account authenticated: nothing here
    /// reads the credential, and the payload has no field for one.
    /// </remarks>
    private static string Describe(SshConnectionRequest request) =>
        string.Create(
            CultureInfo.InvariantCulture,
            $"{request.Username}@{request.Host}:{request.Port}");

    private sealed record LiveSession(ISshConnection Connection, TerminalSessionPump Pump, Task Run);
}
