using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.Extensions.DependencyInjection;

namespace DodoSSH.Api.Tests;

/// <summary>
/// The push channel, over a real socket through the real authentication pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The tests that matter most here are the two negatives: an unauthenticated upgrade is refused, and a
/// change to somebody else's vault does not reach this socket. A push channel that leaked <em>which
/// vault ids exist and when they change</em> would be a disclosure the pull path takes deliberate
/// trouble to avoid — <c>SyncPullEndpoint</c> answers 404 rather than 403 for exactly that reason —
/// and it would be invisible in a test that only checked that notices arrive.
/// </para>
/// <para>
/// Ordering is asserted rather than absence-within-a-timeout wherever possible. "Nothing arrived in
/// two seconds" is a test that passes on a slow machine for the wrong reason; "the first notice this
/// socket saw was about its own vault, although another vault was written to first" is not.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EventsEndpointTests(ApiFixture fixture)
{
    private static readonly DateTimeOffset Now = new(2026, 8, 4, 12, 0, 0, TimeSpan.Zero);

    /// <summary>
    /// How long a test will wait for a frame before calling it a failure.
    /// </summary>
    /// <remarks>
    /// Generous, because it is not a measurement: every wait here is for something already committed,
    /// so the only thing this bounds is how long a genuinely broken build hangs before it reports.
    /// </remarks>
    private static readonly TimeSpan FrameTimeout = TimeSpan.FromSeconds(30);

    // ---- The handshake ----

    [Fact]
    public async Task WithoutAToken_TheUpgradeIsRefused()
    {
        var client = fixture.Server.CreateWebSocketClient();
        client.SubProtocols.Add(VaultEvents.SubProtocol);

        var connecting = client.ConnectAsync(
            new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
            TestContext.Current.CancellationToken);

        await Should.ThrowAsync<InvalidOperationException>(connecting);
    }

    [Fact]
    public async Task BeforeEnrolling_Is403WithAnActionableCode()
    {
        // The same bar as sync: a caller with no identity key holds no vault key either, so every
        // notice this socket could carry is about ciphertext they cannot read.
        var client = fixture.CreateClientFor(NewSubject());

        var response = await client.GetAsync(
            new Uri(VaultEvents.Path, UriKind.Relative),
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(HttpStatusCode.Forbidden);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        problem.Code.ShouldBe(ProblemCodes.EnrollmentRequired);
    }

    [Fact]
    public async Task APlainGet_SaysItIsAWebSocket()
    {
        // A person, or a client with the wrong URL. Answering with a problem document rather than a
        // socket that closes is the difference between a diagnosable mistake and a mysterious one.
        var client = fixture.CreateClientFor(await SeedEnrolledUserAsync());

        var response = await client.GetAsync(
            new Uri(VaultEvents.Path, UriKind.Relative),
            TestContext.Current.CancellationToken);

        response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);

        var problem = await response.Content.ReadProblemAsync();
        problem.ShouldNotBeNull();
        problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
        problem.Detail.ShouldNotBeNull().ShouldContain(VaultEvents.SubProtocol);
    }

    [Fact]
    public async Task AnUpgradeWithoutTheSubprotocol_IsRefused()
    {
        // The subprotocol is this socket's version negotiation, so accepting an upgrade that did not
        // offer it would mean answering a client in a dialect it never agreed to read.
        var (subject, _) = await SeedUserWithVaultAsync();

        var client = fixture.Server.CreateWebSocketClient();
        client.ConfigureRequest = request => request.Headers.Authorization =
            $"Bearer {fixture.IdentityProvider.MintToken(subject)}";

        var connecting = client.ConnectAsync(
            new Uri(fixture.Server.BaseAddress, VaultEvents.Path.TrimStart('/')),
            TestContext.Current.CancellationToken);

        await Should.ThrowAsync<InvalidOperationException>(connecting);
    }

    [Fact]
    public async Task TheFirstFrameIsHello()
    {
        var (subject, _) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        var hello = await ReadAsync(socket, timeout.Token);

        hello.Kind.ShouldBe(VaultEventKinds.Hello);

        // Sent so a client knows when silence means the socket is dead rather than quiet, and so a
        // socket following nothing — a real state, for an account with no vaults — is distinguishable
        // from one that is broken.
        hello.HeartbeatSeconds.ShouldNotBeNull().ShouldBeGreaterThan(0);
        hello.VaultCount.ShouldBe(1);
    }

    [Fact]
    public async Task MetaAdvertisesTheFeature()
    {
        // How a client decides whether to hold a socket open at all. Absence is not an error — it
        // means synchronise on the timer, which is what every client did before this existed.
        var client = fixture.CreateClient();

        var meta = await (await client.GetAsync(
                new Uri("/api/v1/meta", UriKind.Relative),
                TestContext.Current.CancellationToken))
            .Content.ReadContractAsync<MetaResponse>();

        meta.ShouldNotBeNull();
        meta.Features.ShouldContain(
            feature => string.Equals(feature, VaultEvents.Feature, StringComparison.Ordinal));
    }

    // ---- Notices ----

    [Fact]
    public async Task APush_AnnouncesTheVaultToAFollowingSocket()
    {
        var (subject, vaultId) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        var client = fixture.CreateClientFor(subject);
        var push = await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch());
        push.EnsureSuccessStatusCode();

        var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);

        notice.VaultId.ShouldBe(vaultId);

        // A hint for logging and coalescing, never a cursor: cursors are opaque and integrity-tagged,
        // and a client that tried to resume from this would be resuming from a number it invented.
        notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
    }

    [Fact]
    public async Task ANoticeCarriesNoCiphertext()
    {
        // The load-bearing property of the whole design. A notice says only that a vault moved; the
        // client's answer is the delta pull it would have run on its timer anyway, which keeps exactly
        // one code path applying changes. See ADR 0012.
        var (subject, vaultId) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        var client = fixture.CreateClientFor(subject);
        var batch = NewCreateBatch();
        await client.PostContractAsync(PushUrl(vaultId), batch);

        var raw = await ReadRawUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);

        // The envelope this test pushed, as it would appear if a payload had been forwarded.
        raw.ShouldNotContain(Convert.ToBase64String(batch.Operations[0].Payload!.Envelope));
        raw.ShouldNotContain("payload", Case.Insensitive);
        raw.ShouldNotContain(batch.Operations[0].EntityId.ToString());
    }

    [Fact]
    public async Task APushToAnotherAccountsVault_IsNotAnnouncedHere()
    {
        // The disclosure that would matter: a socket learning that vault ids it cannot read exist,
        // and when somebody works on them.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var (stranger, strangersVaultId) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        // The stranger's write goes first, so a socket that leaked would have announced it before the
        // one this test then waits for. Ordering, not a timeout: "nothing arrived in two seconds"
        // passes on a slow machine for the wrong reason.
        var strangersClient = fixture.CreateClientFor(stranger);
        (await strangersClient.PostContractAsync(PushUrl(strangersVaultId), NewCreateBatch()))
            .EnsureSuccessStatusCode();

        var ownClient = fixture.CreateClientFor(subject);
        (await ownClient.PostContractAsync(PushUrl(vaultId), NewCreateBatch()))
            .EnsureSuccessStatusCode();

        var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);

        notice.VaultId.ShouldBe(vaultId);
        notice.VaultId.ShouldNotBe(strangersVaultId);
    }

    [Fact]
    public async Task APushThatAppliedNothing_AnnouncesNothing()
    {
        // A batch of pure conflicts moved no vault. Announcing one anyway would have every client on
        // it pull for a change that is not there.
        var (subject, vaultId) = await SeedUserWithVaultAsync();
        var client = fixture.CreateClientFor(subject);

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        // An update to an item that does not exist: rejected as a conflict, nothing written.
        var stale = new SyncPushRequest(
        [
            NewOperation(Guid.CreateVersion7(), expectedVersion: 7, envelope: [9, 9]),
        ]);

        var conflicted = await client.PostContractAsync(PushUrl(vaultId), stale);
        conflicted.EnsureSuccessStatusCode();

        var results = await conflicted.Content.ReadContractAsync<SyncPushResponse>();
        results.ShouldNotBeNull();
        results.Results[0].Status.ShouldBe(SyncOperationStatus.Conflict);

        // Then a write that did land. The first notice must be that one.
        (await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();

        var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);

        notice.Sequence.ShouldNotBeNull().ShouldBeGreaterThan(0);
    }

    [Fact]
    public async Task APing_IsAnswered()
    {
        var (subject, _) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        await SendAsync(socket, new VaultEvent(VaultEventKinds.Ping), timeout.Token);

        var pong = await ReadUntilAsync(socket, VaultEventKinds.Pong, timeout.Token);

        pong.Kind.ShouldBe(VaultEventKinds.Pong);
    }

    [Fact]
    public async Task AFrameThisServerCannotRead_DoesNotEndTheSocket()
    {
        // A control channel whose failure mode is "the client polls instead" should tolerate a frame
        // from a newer client rather than cost that client its push for the whole session.
        var (subject, vaultId) = await SeedUserWithVaultAsync();

        using var timeout = Timeout();
        using var socket = await fixture.ConnectEventsAsync(subject, timeout.Token);

        await ReadAsync(socket, timeout.Token);

        await socket.SendAsync(
            Encoding.UTF8.GetBytes("{ not json at all"),
            WebSocketMessageType.Text,
            endOfMessage: true,
            timeout.Token);

        var client = fixture.CreateClientFor(subject);
        (await client.PostContractAsync(PushUrl(vaultId), NewCreateBatch())).EnsureSuccessStatusCode();

        var notice = await ReadUntilAsync(socket, VaultEventKinds.VaultChanged, timeout.Token);

        notice.VaultId.ShouldBe(vaultId);
        socket.State.ShouldBe(WebSocketState.Open);
    }

    // ---- Helpers ----

    /// <summary>A token that gives up rather than letting a broken build hang the suite.</summary>
    private static CancellationTokenSource Timeout()
    {
        var source = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);

        source.CancelAfter(FrameTimeout);

        return source;
    }

    private static async Task<VaultEvent> ReadAsync(WebSocket socket, CancellationToken cancellationToken)
    {
        var json = await ReadRawAsync(socket, cancellationToken);

        return JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent)
            ?? throw new InvalidOperationException($"The server sent a null frame: {json}");
    }

    private static async Task<string> ReadRawAsync(WebSocket socket, CancellationToken cancellationToken)
    {
        var buffer = new byte[8 * 1024];

        var received = await socket.ReceiveAsync(buffer, cancellationToken);

        if (received.MessageType == WebSocketMessageType.Close)
        {
            throw new InvalidOperationException(
                $"The server closed the socket: {received.CloseStatus} {received.CloseStatusDescription}");
        }

        return Encoding.UTF8.GetString(buffer, 0, received.Count);
    }

    /// <summary>Reads past the frames a test does not care about — hello, and heartbeats.</summary>
    private static async Task<VaultEvent> ReadUntilAsync(
        WebSocket socket,
        string kind,
        CancellationToken cancellationToken)
    {
        while (true)
        {
            var frame = await ReadAsync(socket, cancellationToken);

            if (string.Equals(frame.Kind, kind, StringComparison.Ordinal))
            {
                return frame;
            }
        }
    }

    /// <summary>
    /// The same, but keeping the bytes.
    /// </summary>
    /// <remarks>
    /// Deserialising and asserting on the fields would prove only that this <em>record</em> has no
    /// payload member, which is a tautology. Asserting on what actually crossed the socket is what
    /// would catch a field added to the frame later without anybody thinking about disclosure.
    /// </remarks>
    private static async Task<string> ReadRawUntilAsync(
        WebSocket socket,
        string kind,
        CancellationToken cancellationToken)
    {
        while (true)
        {
            var json = await ReadRawAsync(socket, cancellationToken);
            var frame = JsonSerializer.Deserialize(json, DodoSshJsonContext.Default.VaultEvent);

            if (string.Equals(frame?.Kind, kind, StringComparison.Ordinal))
            {
                return json;
            }
        }
    }

    private static Task SendAsync(WebSocket socket, VaultEvent frame, CancellationToken cancellationToken) =>
        socket.SendAsync(
            JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent),
            WebSocketMessageType.Text,
            endOfMessage: true,
            cancellationToken);

    private static string PushUrl(Guid vaultId) => $"/api/v1/vaults/{vaultId}/sync/push";

    private static string NewSubject() => $"events-{Guid.CreateVersion7():N}";

    private static SyncPushOperation NewOperation(Guid entityId, int? expectedVersion, byte[] envelope) =>
        new(
            Guid.CreateVersion7(),
            SyncEntityType.Host,
            entityId,
            SyncOperation.Upsert,
            expectedVersion,
            new EncryptedPayload(envelope, [0xD, 0xE], Guid.CreateVersion7(), 1, 1),
            new SyncPlaintextFields());

    private static SyncPushRequest NewCreateBatch() =>
        new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);

    private async Task<string> SeedEnrolledUserAsync()
    {
        var subject = NewSubject();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var user = NewUser(subject);
        database.Users.Add(user);
        database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
        await database.SaveChangesAsync();

        return subject;
    }

    private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
    {
        var subject = NewSubject();

        await using var scope = fixture.CreateScope();
        var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();

        var user = NewUser(subject);

        var vault = new Vault
        {
            Id = Guid.CreateVersion7(),
            Name = "Personal",
            OwnerKind = VaultOwnerKind.Personal,
            OwnerUserId = user.Id,
            KeyGeneration = 1,
            CreatedAtUtc = Now,
            UpdatedAtUtc = Now,
        };

        database.Users.Add(user);
        database.UserKeys.Add(Seed.CurrentKey(user.Id, Now));
        database.Vaults.Add(vault);
        await database.SaveChangesAsync();

        return (subject, vault.Id);
    }

    private UserAccount NewUser(string subject) => new()
    {
        Id = Guid.CreateVersion7(),
        Issuer = fixture.IdentityProvider.Authority,
        Subject = subject,
        Status = UserStatus.Active,
        CreatedAtUtc = Now,
        UpdatedAtUtc = Now,
    };
}
