using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
using DodoSSH.Client.Api;
using DodoSSH.Contracts;

namespace DodoSSH.Client.Api.Tests;

/// <summary>
/// The reconnection policy, which is what this class actually is.
/// </summary>
/// <remarks>
/// <para>
/// A dropped socket is the ordinary case here rather than the exception — laptops sleep, proxies time
/// out, tokens expire, servers are redeployed — so the behaviour worth covering is what happens
/// <em>after</em> a failure, not the happy path. Driven through the injected connector, because the one
/// thing a test cannot do to a real network is make it fail on cue.
/// </para>
/// <para>
/// The backoff is configured down to milliseconds throughout. What is under test is the shape of the
/// policy — does it try again, does it wait, does it stop waiting when told the token was the problem —
/// and none of that depends on the intervals a shipped client uses.
/// </para>
/// </remarks>
public sealed class VaultEventStreamTests
{
    private static readonly Uri Server = new("https://dodossh.example");

    private static readonly VaultEventStreamOptions Impatient = new()
    {
        InitialBackoff = TimeSpan.FromMilliseconds(1),
        MaxBackoff = TimeSpan.FromMilliseconds(5),
        InitialSilenceTimeout = TimeSpan.FromSeconds(30),
    };

    [Fact]
    public async Task ItDialsTheWebSocketFormOfTheServersUrl()
    {
        // https becomes wss, and the path is the one in the contract. Getting either wrong is a client
        // that reconnects against a 404 for the whole session, which from outside is indistinguishable
        // from a network that eats WebSockets.
        var dialled = new List<Uri>();
        var socket = new FakeWebSocket();

        await using var stream = Stream(
            (url, _, _) =>
            {
                dialled.Add(url);
                return Task.FromResult<WebSocket>(socket);
            });

        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));

        await stream.ReadAsync(Token);

        dialled[0].ShouldBe(new Uri("wss://dodossh.example/api/v1/events"));
    }

    [Fact]
    public async Task ItSendsTheBearerTokenOnTheUpgrade()
    {
        // The whole of this socket's authorization, unlike the relay's ticket. See ADR 0012.
        var presented = new List<string>();
        var socket = new FakeWebSocket();

        await using var stream = Stream(
            (_, token, _) =>
            {
                presented.Add(token);
                return Task.FromResult<WebSocket>(socket);
            });

        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));

        await stream.ReadAsync(Token);

        presented[0].ShouldBe(StubTokens.Token);
    }

    [Fact]
    public async Task ANoticeReachesTheReader()
    {
        var vaultId = Guid.CreateVersion7();
        var socket = new FakeWebSocket();

        await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));

        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, vaultId, 42));

        var notice = await stream.ReadAsync(Token);

        notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
        notice.VaultId.ShouldBe(vaultId);
        notice.Sequence.ShouldBe(42);
    }

    [Fact]
    public async Task AHeartbeatIsAnsweredAndNotHandedToTheReader()
    {
        // A ping is housekeeping between the two ends. Passing it up would wake a synchronisation loop
        // every thirty seconds for a frame that says nothing happened.
        var socket = new FakeWebSocket();

        await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));

        socket.Deliver(new VaultEvent(VaultEventKinds.Ping, HeartbeatSeconds: 30));
        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));

        var first = await stream.ReadAsync(Token);

        first.Kind.ShouldBe(VaultEventKinds.VaultChanged, "the ping should not have been forwarded");

        var answered = await socket.SentAsync(Token);
        answered.Kind.ShouldBe(VaultEventKinds.Pong);
    }

    [Fact]
    public async Task AKindThisBuildDoesNotKnow_IsStillHandedOver()
    {
        // What makes the frame table extensible: this class must not decide what a newer server may
        // say. Deciding to ignore it is the caller's, and costs that caller one redundant pass.
        var socket = new FakeWebSocket();

        await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));

        socket.Deliver(new VaultEvent("session.offered"));

        var notice = await stream.ReadAsync(Token);

        notice.Kind.ShouldBe("session.offered");
    }

    [Fact]
    public async Task AFailedDial_IsRetried()
    {
        // No server yet, or no network. Neither is an error to report: the caller's synchronisation
        // timer is running regardless, which is what lets this stay silent and keep trying.
        var attempts = 0;
        var socket = new FakeWebSocket();

        await using var stream = Stream((_, _, _) =>
        {
            if (++attempts < 3)
            {
                throw new WebSocketException("no route to host");
            }

            return Task.FromResult<WebSocket>(socket);
        });

        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));

        var notice = await stream.ReadAsync(Token);

        notice.Kind.ShouldBe(VaultEventKinds.VaultChanged);
        attempts.ShouldBe(3);
    }

    [Fact]
    public async Task ADroppedSocket_IsReplaced()
    {
        // The case that decides whether this feature survives a laptop lid. A stream that gave up on
        // the first close would work all morning and be silently dead after lunch.
        var sockets = new List<FakeWebSocket>();

        await using var stream = Stream((_, _, _) =>
        {
            var socket = new FakeWebSocket();
            sockets.Add(socket);

            if (sockets.Count == 1)
            {
                socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
                socket.Close(WebSocketCloseStatus.EndpointUnavailable);
            }
            else
            {
                socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));
            }

            return Task.FromResult<WebSocket>(socket);
        });

        (await stream.ReadAsync(Token)).Sequence.ShouldBe(1);
        (await stream.ReadAsync(Token)).Sequence.ShouldBe(2);

        sockets.Count.ShouldBeGreaterThanOrEqualTo(2);
    }

    [Fact]
    public async Task AnExpiredTokenClose_ReconnectsAndAsksForAFreshToken()
    {
        // The bound that lets a long-lived socket be authorised by a short-lived credential: the server
        // closes at the token's expiry and the client comes straight back with a new one. The token
        // provider being asked again is the half that matters — reconnecting with the spent token would
        // be an unbroken loop of closes.
        var tokens = new StubTokens();
        var sockets = 0;

        await using var stream = new VaultEventStream(
            Server,
            tokens,
            TimeProvider.System,
            (_, _, _) =>
            {
                var socket = new FakeWebSocket();

                if (++sockets == 1)
                {
                    socket.Close((WebSocketCloseStatus)VaultEvents.TokenExpiredCloseCode);
                }
                else
                {
                    socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 7));
                }

                return Task.FromResult<WebSocket>(socket);
            },
            Impatient);

        (await stream.ReadAsync(Token)).Sequence.ShouldBe(7);

        tokens.Requests.ShouldBeGreaterThanOrEqualTo(2);
    }

    [Fact]
    public async Task TryRead_TakesWhatIsWaitingAndSaysWhenNothingIs()
    {
        // How a caller coalesces a burst: read one, wait a moment, swallow the rest. Without this a
        // colleague tidying a folder would produce a synchronisation pass per item.
        var socket = new FakeWebSocket();

        await using var stream = Stream((_, _, _) => Task.FromResult<WebSocket>(socket));

        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 1));
        socket.Deliver(new VaultEvent(VaultEventKinds.VaultChanged, Guid.CreateVersion7(), 2));

        (await stream.ReadAsync(Token)).Sequence.ShouldBe(1);

        // Delivery is asynchronous, so the second may not have landed yet; this is the same
        // wait-then-drain the caller performs.
        await Task.Delay(TimeSpan.FromMilliseconds(200), Token);

        stream.TryRead(out var queued).ShouldBeTrue();
        queued.Sequence.ShouldBe(2);

        stream.TryRead(out _).ShouldBeFalse();
    }

    [Fact]
    public async Task AnIdleStream_NeverDelivers()
    {
        // What a server without the feature supplies. Waiting for ever rather than completing is the
        // point: a caller selecting between this and a timer has to fall through to the timer, and a
        // read that returned at once would spin that loop as fast as the machine allows.
        using var stream = IdleVaultEventStream.Instance;
        using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);

        giveUp.CancelAfter(TimeSpan.FromMilliseconds(100));

        await Should.ThrowAsync<OperationCanceledException>(
            async () => await stream.ReadAsync(giveUp.Token));

        stream.TryRead(out _).ShouldBeFalse();
        stream.IsConnected.ShouldBeFalse();
    }

    private static CancellationToken Token => TestContext.Current.CancellationToken;

    private static VaultEventStream Stream(
        Func<Uri, string, CancellationToken, Task<WebSocket>> connect) =>
        new(Server, new StubTokens(), TimeProvider.System, connect, Impatient);

    /// <summary>A token provider that hands out one value and counts who asked.</summary>
    private sealed class StubTokens : IAccessTokenProvider
    {
        internal const string Token = "access-token";

        internal int Requests { get; private set; }

        public ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
        {
            Requests++;

            return ValueTask.FromResult(Token);
        }
    }

    /// <summary>
    /// A socket a test writes the server's half of.
    /// </summary>
    /// <remarks>
    /// Frames queued with <see cref="Deliver"/> are handed out by <see cref="ReceiveAsync"/> in order;
    /// once the queue is empty the receive waits, which is what an idle connection does. <see
    /// cref="Close"/> queues the close instead, so a test can script "two notices and then the server
    /// went away" as a value rather than as a race.
    /// </remarks>
    private sealed class FakeWebSocket : WebSocket
    {
        private readonly Channel<byte[]> inbound = Channel.CreateUnbounded<byte[]>();
        private readonly Channel<VaultEvent> outbound = Channel.CreateUnbounded<VaultEvent>();

        private WebSocketCloseStatus? closing;
        private WebSocketState state = WebSocketState.Open;

        public override WebSocketCloseStatus? CloseStatus => closing;

        public override string? CloseStatusDescription => null;

        public override WebSocketState State => state;

        public override string? SubProtocol => VaultEvents.SubProtocol;

        /// <summary>Queues a frame for the client to read.</summary>
        internal void Deliver(VaultEvent frame) =>
            inbound.Writer.TryWrite(
                JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent));

        /// <summary>Ends the socket, after everything already queued has been read.</summary>
        internal void Close(WebSocketCloseStatus status)
        {
            closing = status;
            inbound.Writer.TryWrite([]);
        }

        /// <summary>The next frame the client sent.</summary>
        internal ValueTask<VaultEvent> SentAsync(CancellationToken cancellationToken) =>
            outbound.Reader.ReadAsync(cancellationToken);

        public override async Task<WebSocketReceiveResult> ReceiveAsync(
            ArraySegment<byte> buffer,
            CancellationToken cancellationToken)
        {
            var frame = await inbound.Reader.ReadAsync(cancellationToken);

            // The empty frame Close queues. Reported as a close, exactly as a real socket does once the
            // peer's close frame arrives.
            if (frame.Length == 0)
            {
                state = WebSocketState.Closed;

                return new WebSocketReceiveResult(
                    0, WebSocketMessageType.Close, endOfMessage: true, closing, null);
            }

            frame.CopyTo(buffer.Array!, buffer.Offset);

            return new WebSocketReceiveResult(frame.Length, WebSocketMessageType.Text, endOfMessage: true);
        }

        public override Task SendAsync(
            ArraySegment<byte> buffer,
            WebSocketMessageType messageType,
            bool endOfMessage,
            CancellationToken cancellationToken)
        {
            var json = Encoding.UTF8.GetString(buffer.Array!, buffer.Offset, buffer.Count);

            if (JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent) is { } frame)
            {
                outbound.Writer.TryWrite(frame);
            }

            return Task.CompletedTask;
        }

        public override void Abort() => state = WebSocketState.Aborted;

        public override Task CloseAsync(
            WebSocketCloseStatus closeStatus,
            string? statusDescription,
            CancellationToken cancellationToken) => CloseOutputAsync(
            closeStatus, statusDescription, cancellationToken);

        public override Task CloseOutputAsync(
            WebSocketCloseStatus closeStatus,
            string? statusDescription,
            CancellationToken cancellationToken)
        {
            state = WebSocketState.Closed;

            return Task.CompletedTask;
        }

        public override void Dispose() => state = WebSocketState.Closed;
    }
}
