/*
 * Happy Gopher Server
 * Copyright (c) 2026 Kyle Givler
 * Licensed under the MIT License.
 */

using HappyGopher.Events;
using HappyGopher.Extensibility;
using HappyGopher.Gopher;
using HappyGopher.Pages;
using HappyGopher.Pages.Guestbook;
using HappyGopher.Telemetry;
using JoyfulReaperLib.MissionControl;
using JoyfulReaperLib.TcpServer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json.Serialization.Metadata;
using Xunit.Sdk;

namespace HappyGopher.Tests;

public sealed class HappyGopherIntegrationTests
{
    private sealed class NullMissionControlClient : IMissionControlClient
    {
        public static NullMissionControlClient Instance { get; } = new();

        private NullMissionControlClient()
        {
        }

        public Task<bool> TryPublishAsync<TPayload>(
            string eventType,
            TPayload payload,
            JsonTypeInfo<TPayload> payloadTypeInfo,
            DateTimeOffset occurredAt,
            string? correlationId = null,
            CancellationToken cancellationToken = default) =>
            Task.FromResult(false);
    }

    private sealed class FalseMissionControlClient : IMissionControlClient
    {
        public static FalseMissionControlClient Instance { get; } = new();

        private FalseMissionControlClient()
        {
        }

        public Task<bool> TryPublishAsync<TPayload>(
            string eventType,
            TPayload payload,
            JsonTypeInfo<TPayload> payloadTypeInfo,
            DateTimeOffset occurredAt,
            string? correlationId = null,
            CancellationToken cancellationToken = default)
        {
            if (eventType != GopherServiceStartedEvent.EventName)
            {
                return Task.FromResult(true);
            }

            return Task.FromResult(false);
        }
    }

    private sealed class ThrowingMissionControlClient : IMissionControlClient
    {
        public Task<bool> TryPublishAsync<TPayload>(
            string eventType,
            TPayload payload,
            JsonTypeInfo<TPayload> payloadTypeInfo,
            DateTimeOffset occurredAt,
            string? correlationId = null,
            CancellationToken cancellationToken = default)
        {
            if (eventType != GopherServiceStartedEvent.EventName)
            {
                return Task.FromResult(true);
            }

            throw new InvalidOperationException("Telemetry failure");
        }
    }

    private sealed class StartupTimeoutMissionControlClient :
        IMissionControlClient
    {
        private int _canceledCount;

        public int CanceledCount => Volatile.Read(ref _canceledCount);

        public async Task<bool> TryPublishAsync<TPayload>(
            string eventType,
            TPayload payload,
            JsonTypeInfo<TPayload> payloadTypeInfo,
            DateTimeOffset occurredAt,
            string? correlationId = null,
            CancellationToken cancellationToken = default)
        {
            if (eventType != GopherServiceStartedEvent.EventName)
            {
                return true;
            }

            try
            {
                await Task.Delay(
                    Timeout.InfiniteTimeSpan,
                    cancellationToken);
            }
            catch (OperationCanceledException)
            {
                Interlocked.Increment(ref _canceledCount);
                throw;
            }

            return true;
        }
    }

    private sealed class BlockingMissionControlClient : IMissionControlClient
    {
        private readonly TaskCompletionSource _release =
            new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly SemaphoreSlim _startedSignal = new(0);
        private readonly SemaphoreSlim _finishedSignal = new(0);
        private readonly bool _throwAfterRelease;
        private readonly bool _publishResult;
        private int _startedCount;
        private int _finishedCount;
        private int _canceledCount;

        public BlockingMissionControlClient(
            bool throwAfterRelease = false,
            bool publishResult = true)
        {
            _throwAfterRelease = throwAfterRelease;
            _publishResult = publishResult;
        }

        public int StartedCount => Volatile.Read(ref _startedCount);
        public int FinishedCount => Volatile.Read(ref _finishedCount);
        public int CanceledCount => Volatile.Read(ref _canceledCount);

        public async Task<bool> TryPublishAsync<TPayload>(
            string eventType,
            TPayload payload,
            JsonTypeInfo<TPayload> payloadTypeInfo,
            DateTimeOffset occurredAt,
            string? correlationId = null,
            CancellationToken cancellationToken = default)
        {
            if (eventType != SelectorServedEventName)
            {
                return true;
            }

            Interlocked.Increment(ref _startedCount);
            _startedSignal.Release();

            try
            {
                await _release.Task.WaitAsync(cancellationToken);
            }
            catch (OperationCanceledException)
            {
                Interlocked.Increment(ref _canceledCount);
                throw;
            }
            finally
            {
                Interlocked.Increment(ref _finishedCount);
                _finishedSignal.Release();
            }

            if (_throwAfterRelease)
            {
                throw new InvalidOperationException("Telemetry failure");
            }

            return _publishResult;
        }

        public void Release() =>
            _release.TrySetResult();

        public async Task WaitForStartedCountAsync(
            int expectedCount,
            CancellationToken cancellationToken)
        {
            while (StartedCount < expectedCount)
            {
                await _startedSignal.WaitAsync(cancellationToken);
            }
        }

        public async Task WaitForFinishedCountAsync(
            int expectedCount,
            CancellationToken cancellationToken)
        {
            while (FinishedCount < expectedCount)
            {
                await _finishedSignal.WaitAsync(cancellationToken);
            }
        }
    }

    private sealed class CancellationBlockingPage : IGopherPage
    {
        private readonly TaskCompletionSource _started =
            new(TaskCreationOptions.RunContinuationsAsynchronously);
        private readonly TaskCompletionSource _canceled =
            new(TaskCreationOptions.RunContinuationsAsynchronously);

        public string Selector => "/blocking-response";

        public Task Started => _started.Task;

        public Task Canceled => _canceled.Task;

        public async Task<GopherResponseKind> WriteAsync(
            GopherRequest request,
            Stream output,
            CancellationToken cancellationToken)
        {
            _started.TrySetResult();

            try
            {
                await Task.Delay(
                    Timeout.InfiniteTimeSpan,
                    cancellationToken);
            }
            catch (OperationCanceledException)
                when (cancellationToken.IsCancellationRequested)
            {
                _canceled.TrySetResult();
                throw;
            }

            return GopherResponseKind.Text;
        }
    }

    private const string SelectorServedEventName =
        "happygopher.selector.served";

    private static readonly Encoding WireEncoding = new UTF8Encoding(false);

    [Fact]
    public async Task Server_PublishesStartupTelemetry()
    {
        RecordingMissionControlClient recording = new();

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                GopherServiceStartedEvent.EventName);

        Assert.Equal(
            GopherServiceStartedEvent.EventName,
            telemetry.EventType);
        Assert.NotEqual(default, telemetry.OccurredAt);
        Assert.Null(telemetry.CorrelationId);

        GopherServiceStartedEvent payload =
            Assert.IsType<GopherServiceStartedEvent>(
                telemetry.Payload);
        Assert.Equal(
            $"127.0.0.1:{server.Port}",
            payload.ListenAddress);
    }

    [Fact]
    public async Task Server_DualModeRespondsThroughIPv4AndIPv6AndFormatsStartupTelemetry()
    {
        await RequireDualStackLoopbackCapabilityAsync();

        RecordingMissionControlClient recording = new();

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                listenAddress: "::",
                dualMode: true);

        server.Content.WriteText("dual-stack.txt", "Dual stack response");

        string ipv4Response = await server.RequestAsync(
            "/dual-stack.txt",
            IPAddress.Loopback);
        string ipv6Response = await server.RequestAsync(
            "/dual-stack.txt",
            IPAddress.IPv6Loopback);

        Assert.Equal("Dual stack response\r\n.\r\n", ipv4Response);
        Assert.Equal(ipv4Response, ipv6Response);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(
            GopherServiceStartedEvent.EventName,
            1,
            timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                GopherServiceStartedEvent.EventName);
        GopherServiceStartedEvent payload =
            Assert.IsType<GopherServiceStartedEvent>(telemetry.Payload);

        Assert.Equal($"[::]:{server.Port}", payload.ListenAddress);
        Assert.DoesNotContain(":::", payload.ListenAddress);
    }

    [Fact]
    public async Task Server_DualModePublishesTelemetryForIPv6AfterClose()
    {
        await RequireDualStackLoopbackCapabilityAsync();

        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                listenAddress: "::",
                dualMode: true);
        server.Content.WriteText("about.txt", "About over IPv6");

        string response = await server.RequestAsync(
            "/about.txt",
            IPAddress.IPv6Loopback);

        Assert.Equal("About over IPv6\r\n.\r\n", response);

        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(
            SelectorServedEventName,
            1,
            timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType == SelectorServedEventName);
        SelectorServedEvent payload =
            Assert.IsType<SelectorServedEvent>(telemetry.Payload);

        Assert.Equal("/about.txt", payload.Selector);
        Assert.Matches(@"^\[::1\]:\d+$", payload.Remote);
    }

    [Fact]
    public async Task Server_PublishesDynamicPageResponseKindTelemetry()
    {
        RecordingMissionControlClient recording = new();

        TestGopherPage page = new(
            selector: "/dynamic/telemetry",
            response:
                "iDynamic telemetry\tfake\t(NULL)\t0\r\n.\r\n",
            responseKind: GopherResponseKind.Menu);

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                pages: new IGopherPage[] { page });

        string response =
            await server.RequestAsync("/dynamic/telemetry");

        Assert.Equal(
            "iDynamic telemetry\tfake\t(NULL)\t0\r\n.\r\n",
            response);

        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));

        await recording.WaitForPublishedEventCountAsync(
            SelectorServedEventName,
            1,
            timeout.Token);

        RecordedMissionControlEvent telemetry =
            Assert.Single(
                recording.PublishedEvents,
                publishedEvent =>
                    publishedEvent.EventType ==
                    SelectorServedEventName);

        Assert.False(
            string.IsNullOrWhiteSpace(
                telemetry.CorrelationId));

        SelectorServedEvent payload =
            Assert.IsType<SelectorServedEvent>(
                telemetry.Payload);

        Assert.Equal(
            "/dynamic/telemetry",
            payload.Selector);

        Assert.Equal(
            "menu",
            payload.ResponseType);

        Assert.True(payload.Succeeded);
        Assert.True(
            payload.DurationMilliseconds >= 0);
    }

    [Fact]
    public async Task Server_FallsBackToStaticContentWhenNoDynamicPageMatches()
    {
        TestGopherPage page = new(
            selector: "/dynamic/test",
            response: "Dynamic page\r\n.\r\n");

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: new IGopherPage[] { page });

        server.Content.WriteText(
            "about.txt",
            "Static page");

        string response =
            await server.RequestAsync("/about.txt");

        Assert.Equal(
            "Static page\r\n.\r\n",
            response);
    }

    [Fact]
    public async Task Server_DynamicPageTakesPrecedenceOverStaticContent()
    {
        TestGopherPage page = new(
            selector: "/dynamic/test.txt",
            response: "Dynamic page\r\n.\r\n");

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: new IGopherPage[] { page });

        server.Content.WriteText(
            "dynamic/test.txt",
            "Static page");

        string response =
            await server.RequestAsync("/dynamic/test.txt");

        Assert.Equal(
            "Dynamic page\r\n.\r\n",
            response);

        GopherRequest request =
            Assert.IsType<GopherRequest>(page.LastRequest);

        Assert.Equal(
            "/dynamic/test.txt",
            request.Selector);

        Assert.Null(request.Input);
    }

    [Fact]
    public async Task Server_PassesType7InputToDynamicPage()
    {
        TestGopherPage page = new(
            selector: "/guestbook/add",
            response: "Saved\r\n.\r\n");

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: new IGopherPage[] { page });

        string response =
            await server.RequestAsync(
                "/guestbook/add\tHello from Gopher");

        Assert.Equal(
            "Saved\r\n.\r\n",
            response);

        GopherRequest request =
            Assert.IsType<GopherRequest>(page.LastRequest);

        Assert.Equal(
            "/guestbook/add",
            request.Selector);

        Assert.Equal(
            "Hello from Gopher",
            request.Input);
    }

    [Fact]
    public async Task Server_DoesNotPublishTelemetryForIgnoredRemoteAddress()
    {
        RecordingMissionControlClient recording =
            new();

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                telemetryIgnoredRemoteAddress:
                    IPAddress.Loopback.ToString());

        server.Content.WriteText(
            "gophermap",
            "iRoot");

        string response =
            await server.RequestAsync(string.Empty);

        Assert.Equal(
            "iRoot\tfake\t(NULL)\t0\r\n.\r\n",
            response);

        Assert.DoesNotContain(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
    }

    [Fact]
    public async Task Server_CombinedIgnoreRuleSuppressesOnlyMatchingSelectorAndAddress()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                telemetryIgnoredRemoteAddress:
                    IPAddress.Loopback.ToString(),
                telemetryIgnoredSelectors: ["/healthz"]);
        server.Content.WriteText("about.txt", "About");

        string normalResponse =
            await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", normalResponse);

        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(
            SelectorServedEventName,
            1,
            timeout.Token);

        string healthResponse = await server.RequestAsync("/healthz");

        Assert.Equal("OK\r\n.\r\n", healthResponse);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType == SelectorServedEventName);
        SelectorServedEvent payload =
            Assert.IsType<SelectorServedEvent>(telemetry.Payload);
        Assert.Equal("/about.txt", payload.Selector);
    }

    [Fact]
    public async Task Server_ServesRegisteredDynamicPage()
    {
        TestGopherPage page = new(
            selector: "/dynamic/test",
            response: "Dynamic page\r\n.\r\n");

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: new IGopherPage[] { page });

        string response =
            await server.RequestAsync("/dynamic/test");

        Assert.Equal(
            "Dynamic page\r\n.\r\n",
            response);
    }

    [Fact]
    public async Task Server_ReturnsRootSelectorResponse()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync();
        server.Content.WriteText("gophermap", "iRoot");

        string response = await server.RequestAsync(string.Empty);

        Assert.Equal("iRoot\tfake\t(NULL)\t0\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_PublishesRootSelectorTelemetry()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("gophermap", "iRoot");

        string response = await server.RequestAsync(string.Empty);

        Assert.Equal("iRoot\tfake\t(NULL)\t0\r\n.\r\n", response);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
        Assert.Equal(SelectorServedEventName, telemetry.EventType);
        Assert.NotEqual(default, telemetry.OccurredAt);
        Assert.False(string.IsNullOrWhiteSpace(telemetry.CorrelationId));

        SelectorServedEvent payload = Assert.IsType<SelectorServedEvent>(telemetry.Payload);
        Assert.Equal(string.Empty, payload.Selector);
        Assert.Equal("menu", payload.ResponseType);
        Assert.True(payload.Succeeded);
        Assert.True(payload.DurationMilliseconds >= 0);
    }

    [Fact]
    public async Task Server_SendsEofBeforeTelemetryCompletes()
    {
        BlockingMissionControlClient blocking = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(blocking);
        server.Content.WriteText("gophermap", "iRoot");

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));

        string response = await server.RequestAsync(string.Empty);
        await blocking.WaitForStartedCountAsync(1, timeout.Token);

        Assert.Equal("iRoot\tfake\t(NULL)\t0\r\n.\r\n", response);
        Assert.Equal(0, blocking.FinishedCount);

        blocking.Release();
        await blocking.WaitForFinishedCountAsync(1, timeout.Token);
    }

    [Fact]
    public async Task Server_ReleasesConnectionSlotBeforeTelemetryCompletes()
    {
        BlockingMissionControlClient blocking = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: blocking,
                maxConcurrentConnections: 1);
        server.Content.WriteText("about.txt", "About");

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));

        string firstResponse = await server.RequestAsync("/about.txt");
        await blocking.WaitForStartedCountAsync(1, timeout.Token);
        Assert.Equal(0, blocking.FinishedCount);

        string secondResponse = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", firstResponse);
        Assert.Equal("About\r\n.\r\n", secondResponse);

        blocking.Release();
        await blocking.WaitForFinishedCountAsync(2, timeout.Token);
    }

    [Fact]
    public async Task Server_HoldsConnectionSlotWhileWaitingForSelector()
    {
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(maxConcurrentConnections: 1);
        server.Content.WriteText("about.txt", "About");

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        using TcpClient firstClient = new();
        await firstClient.ConnectAsync(
            IPAddress.Loopback,
            server.Port,
            timeout.Token);

        await using NetworkStream firstStream = firstClient.GetStream();
        await firstStream.WriteAsync(
            WireEncoding.GetBytes("/incomplete"),
            timeout.Token);

        Task<string> secondRequest = server.RequestAsync("/about.txt");

        await Task.Delay(TimeSpan.FromMilliseconds(250), timeout.Token);
        Assert.False(secondRequest.IsCompleted);

        firstClient.Dispose();

        string secondResponse = await secondRequest.WaitAsync(timeout.Token);
        Assert.Equal("About\r\n.\r\n", secondResponse);
    }

    [Fact]
    public async Task Server_NormalResponseCompletesBeforeResponseTimeout()
    {
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                responseTimeoutSeconds: 1);
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_ResponseTimeoutCancelsPageAndReleasesConnectionSlot()
    {
        CancellationBlockingPage page = new();
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: recording,
                maxConcurrentConnections: 1,
                pages: [page],
                responseTimeoutSeconds: 1);
        server.Content.WriteText("about.txt", "About");
        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));

        Task<string> blockedResponse =
            server.RequestAsync(page.Selector);
        await page.Started.WaitAsync(timeout.Token);

        Task<string> secondResponse =
            server.RequestAsync("/about.txt");

        await page.Canceled.WaitAsync(timeout.Token);
        Assert.Equal(
            string.Empty,
            await blockedResponse.WaitAsync(timeout.Token));
        Assert.Equal(
            "About\r\n.\r\n",
            await secondResponse.WaitAsync(timeout.Token));

        await recording.WaitForPublishedEventCountAsync(
            SelectorServedEventName,
            1,
            timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
        SelectorServedEvent payload =
            Assert.IsType<SelectorServedEvent>(telemetry.Payload);

        Assert.Equal("/about.txt", payload.Selector);
        Assert.True(payload.Succeeded);
    }

    [Fact]
    public async Task Server_ShutdownCancelsBlockedResponseBeforeResponseTimeout()
    {
        CancellationBlockingPage page = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: [page],
                responseTimeoutSeconds: 60);
        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));

        Task<string> blockedResponse =
            server.RequestAsync(page.Selector);
        await page.Started.WaitAsync(timeout.Token);

        await server.StopAsync(timeout.Token);

        await page.Canceled.WaitAsync(timeout.Token);
        Assert.Equal(
            string.Empty,
            await blockedResponse.WaitAsync(timeout.Token));
    }

    [Fact]
    public async Task Server_ReturnsTextFileResponse()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync();
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_ServesExtensionlessAsciiFileAsText()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("readme", "First line\n.hidden");

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/readme");

        Assert.Equal("text", responseType);
        Assert.Equal(
            WireEncoding.GetBytes("First line\r\n..hidden\r\n.\r\n"),
            response);
    }

    [Fact]
    public async Task Server_ServesExtensionlessUtf8FileAsText()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("welcome", "Héllo\n世界");

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/welcome");

        Assert.Equal("text", responseType);
        Assert.Equal(
            WireEncoding.GetBytes("Héllo\r\n世界\r\n.\r\n"),
            response);
    }

    [Fact]
    public async Task Server_ServesExtensionlessFileContainingNulAsBinary()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        byte[] contents = [0x41, 0x00, 0x42, 0x0D, 0x0A, 0x2E];
        server.Content.WriteBytes("nul-data", contents);

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/nul-data");

        Assert.Equal("binary", responseType);
        Assert.Equal(contents, response);
    }

    [Fact]
    public async Task Server_ServesExtensionlessFileContainingControlByteAsBinary()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        byte[] contents = [0x41, 0x01, 0x42, 0x0D, 0x0A, 0x2E];
        server.Content.WriteBytes("control-data", contents);

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/control-data");

        Assert.Equal("binary", responseType);
        Assert.Equal(contents, response);
    }

    [Fact]
    public async Task Server_AllowsCrLfAndTabInExtensionlessTextFile()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        server.Content.WriteBytes(
            "whitespace",
            WireEncoding.GetBytes(
                "first\rsecond\nthird\tvalue\r\nfourth"));

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/whitespace");

        Assert.Equal("text", responseType);
        Assert.Equal(
            WireEncoding.GetBytes(
                "first\r\nsecond\r\nthird\tvalue\r\nfourth\r\n.\r\n"),
            response);
    }

    [Fact]
    public async Task Server_ServesKnownTextExtensionAsText()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("known.txt", "Known text");

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/known.txt");

        Assert.Equal("text", responseType);
        Assert.Equal(
            WireEncoding.GetBytes("Known text\r\n.\r\n"),
            response);
    }

    [Fact]
    public async Task Server_ServesUnknownNamedExtensionAsBinaryWhenContentsLookTextual()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(recording);
        byte[] contents = WireEncoding.GetBytes("Looks like text\r\n.");
        server.Content.WriteBytes("unknown.custom", contents);

        (byte[] response, string responseType) =
            await RequestWithResponseTypeAsync(
                server,
                recording,
                "/unknown.custom");

        Assert.Equal("binary", responseType);
        Assert.Equal(contents, response);
    }

    [Fact]
    public async Task Server_GeneratedMenuAdvertisesExtensionlessTextFileAsTypeZero()
    {
        await using TestGopherServer server =
            await TestGopherServer.StartAsync();
        server.Content.WriteText("readme", "Read me");

        string response = await server.RequestAsync(string.Empty);

        Assert.Contains(
            $"0readme\t/readme\t127.0.0.1\t{server.Port}\r\n",
            response);
    }

    [Fact]
    public async Task Server_GeneratedMenuAdvertisesExtensionlessBinaryFileAsTypeNine()
    {
        await using TestGopherServer server =
            await TestGopherServer.StartAsync();
        server.Content.WriteBytes("payload", [0x41, 0x00, 0x42]);

        string response = await server.RequestAsync(string.Empty);

        Assert.Contains(
            $"9payload\t/payload\t127.0.0.1\t{server.Port}\r\n",
            response);
    }

    [Fact]
    public async Task Server_PublishesTextFileTelemetry()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
        SelectorServedEvent payload = Assert.IsType<SelectorServedEvent>(telemetry.Payload);
        Assert.Equal("/about.txt", payload.Selector);
        Assert.Equal("text", payload.ResponseType);
        Assert.True(payload.Succeeded);
    }

    [Fact]
    public async Task Server_PublishesDirectoryTelemetry()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("downloads/gophermap", "iDownloads");

        string response = await server.RequestAsync("/downloads");

        Assert.Contains("iDownloads", response);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
        SelectorServedEvent payload = Assert.IsType<SelectorServedEvent>(telemetry.Payload);
        Assert.Equal("/downloads", payload.Selector);
        Assert.Equal("menu", payload.ResponseType);
        Assert.True(payload.Succeeded);
    }

    [Fact]
    public async Task Server_ReturnsMissingSelectorResponse()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync();

        string response = await server.RequestAsync("/missing.txt");

        Assert.Contains("3Selector not found.\terror\t127.0.0.1\t", response);
        Assert.EndsWith(".\r\n", response);
    }

    [Fact]
    public async Task Server_PublishesMissingSelectorTelemetry()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(recording);

        string response = await server.RequestAsync("/missing.txt");

        Assert.Contains("3Selector not found.\terror\t127.0.0.1\t", response);
        Assert.EndsWith(".\r\n", response);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType ==
                SelectorServedEventName);
        Assert.Equal(SelectorServedEventName, telemetry.EventType);

        SelectorServedEvent payload = Assert.IsType<SelectorServedEvent>(telemetry.Payload);
        Assert.Equal("/missing.txt", payload.Selector);
        Assert.Equal("not-found", payload.ResponseType);
        Assert.False(payload.Succeeded);
    }

    [Fact]
    public async Task Server_HandlesMultipleConcurrentClients()
    {
        RecordingMissionControlClient recording = new();
        await using TestGopherServer server = await TestGopherServer.StartAsync(recording);
        server.Content.WriteText("about.txt", "About");

        Task<string>[] requests = Enumerable.Range(0, 8)
            .Select(_ => server.RequestAsync("/about.txt"))
            .ToArray();

        string[] responses = await Task.WhenAll(requests);
        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(GopherServiceStartedEvent.EventName, 1, timeout.Token);

        RecordedMissionControlEvent[] selectorTelemetry =
            recording.PublishedEvents
                .Where(
                    publishedEvent =>
                        publishedEvent.EventType ==
                        SelectorServedEventName)
                .ToArray();

        Assert.All(responses, response => Assert.Equal("About\r\n.\r\n", response));
        Assert.Equal(requests.Length, selectorTelemetry.Length);
        Assert.All(selectorTelemetry, telemetry =>
        {
            Assert.Equal(SelectorServedEventName, telemetry.EventType);
            SelectorServedEvent payload = Assert.IsType<SelectorServedEvent>(telemetry.Payload);
            Assert.Equal("/about.txt", payload.Selector);
            Assert.Equal("text", payload.ResponseType);
            Assert.True(payload.Succeeded);
        });
    }

    [Fact]
    public async Task Server_StartupTelemetryReturningFalseDoesNotPreventRequests()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync(FalseMissionControlClient.Instance);
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_StartupTelemetryThrowingDoesNotPreventRequests()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync(new ThrowingMissionControlClient());
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_StartupTelemetryTimingOutDoesNotPreventRequests()
    {
        StartupTimeoutMissionControlClient timeoutClient = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(timeoutClient);
        server.Content.WriteText("about.txt", "About");

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
        Assert.Equal(1, timeoutClient.CanceledCount);
    }

    [Fact]
    public async Task Server_ContinuesServingAfterTelemetryException()
    {
        BlockingMissionControlClient blocking = new(throwAfterRelease: true);
        await using TestGopherServer server = await TestGopherServer.StartAsync(blocking);
        server.Content.WriteText("about.txt", "About");

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));

        string firstResponse = await server.RequestAsync("/about.txt");
        await blocking.WaitForStartedCountAsync(1, timeout.Token);

        blocking.Release();
        await blocking.WaitForFinishedCountAsync(1, timeout.Token);

        string secondResponse = await server.RequestAsync("/about.txt");
        await blocking.WaitForStartedCountAsync(2, timeout.Token);

        Assert.Equal("About\r\n.\r\n", firstResponse);
        Assert.Equal("About\r\n.\r\n", secondResponse);
    }

    [Fact]
    public async Task Server_TelemetryTimeoutDoesNotHangHandler()
    {
        BlockingMissionControlClient blocking = new();
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                missionControlClient: blocking,
                maxConcurrentConnections: 1);
        server.Content.WriteText("about.txt", "About");

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));

        string response = await server.RequestAsync("/about.txt");
        await blocking.WaitForStartedCountAsync(1, timeout.Token);
        await blocking.WaitForFinishedCountAsync(1, timeout.Token);

        Assert.Equal("About\r\n.\r\n", response);
        Assert.Equal(1, blocking.CanceledCount);
    }

    private static async Task<(byte[] Response, string ResponseType)>
        RequestWithResponseTypeAsync(
            TestGopherServer server,
            RecordingMissionControlClient recording,
            string selector)
    {
        byte[] response = await server.RequestBytesAsync(selector);

        using CancellationTokenSource timeout =
            new(TimeSpan.FromSeconds(5));
        await recording.WaitForPublishedEventCountAsync(
            SelectorServedEventName,
            1,
            timeout.Token);

        RecordedMissionControlEvent telemetry = Assert.Single(
            recording.PublishedEvents,
            publishedEvent =>
                publishedEvent.EventType == SelectorServedEventName);
        SelectorServedEvent payload =
            Assert.IsType<SelectorServedEvent>(telemetry.Payload);

        Assert.Equal(selector, payload.Selector);
        Assert.True(payload.Succeeded);

        return (response, payload.ResponseType);
    }

    [Fact]
    public async Task Server_DoesNotLeakConnectionSlotAfterClientDisconnect()
    {
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(maxConcurrentConnections: 1);
        server.Content.WriteText("about.txt", "About");

        using TcpClient client = new();
        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
        await client.ConnectAsync(IPAddress.Loopback, server.Port, timeout.Token);
        client.Dispose();

        string response = await server.RequestAsync("/about.txt");

        Assert.Equal("About\r\n.\r\n", response);
    }

    [Fact]
    public async Task Server_StopsGracefullyWhileClientConnectionExists()
    {
        await using TestGopherServer server = await TestGopherServer.StartAsync();
        using TcpClient client = new();
        await client.ConnectAsync(IPAddress.Loopback, server.Port);

        using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));

        await server.StopAsync(timeout.Token);
    }

    [Fact]
    public async Task Server_GuestbookTypeSevenRequestStoresOnceSuppressesReplayAndDisplaysEntry()
    {
        using TemporaryDirectory temporaryDirectory = new();
        GuestbookOptions guestbookOptions = new()
        {
            Enabled = true,
            DataPath = temporaryDirectory.GetPath("guestbook.jsonl"),
            MaxEntriesDisplayed = 25
        };
        FileGuestbookStore guestbookStore = new(
            Options.Create(guestbookOptions),
            NullLogger<FileGuestbookStore>.Instance,
            TimeProvider.System);
        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                guestbookStore: guestbookStore,
                guestbookOptions: guestbookOptions);
        const string request =
            "guestbook/sign\tKyle | integration test";

        string firstResponse = await server.RequestAsync(request);
        IReadOnlyList<GuestbookEntry> entriesAfterFirstRequest =
            await guestbookStore.GetEntriesAsync(take: 10);

        string replayResponse = await server.RequestAsync(request);
        IReadOnlyList<GuestbookEntry> entriesAfterReplay =
            await guestbookStore.GetEntriesAsync(take: 10);
        string viewResponse = await server.RequestAsync("guestbook");

        GuestbookEntry entry = Assert.Single(entriesAfterFirstRequest);
        Assert.Equal("Kyle", entry.Name);
        Assert.Equal("integration test", entry.Message);
        Assert.Single(entriesAfterReplay);
        Assert.EndsWith(".\r\n", firstResponse);
        Assert.EndsWith(".\r\n", replayResponse);
        Assert.Contains("iKyle\tfake\t(NULL)\t0\r\n", viewResponse);
        Assert.Contains(
            "i  integration test\tfake\t(NULL)\t0\r\n",
            viewResponse);
        Assert.EndsWith(".\r\n", viewResponse);
    }

    [Fact]
    public async Task Server_PassesConnectionEndpointsToDynamicPage()
    {
        await RequireDualStackLoopbackCapabilityAsync();

        TestGopherPage page = new(
            selector: "/capture",
            response: "OK\r\n.\r\n");

        await using TestGopherServer server =
            await TestGopherServer.StartAsync(
                pages: new IGopherPage[] { page },
                listenAddress: "::",
                dualMode: true);

        string response = await server.RequestAsync(
            "/capture",
            IPAddress.IPv6Loopback);

        Assert.Equal(
            "OK\r\n.\r\n",
            response);

        GopherRequest request =
            Assert.IsType<GopherRequest>(
                page.LastRequest);

        IPEndPoint remote =
            Assert.IsType<IPEndPoint>(
                request.RemoteEndPoint);

        IPEndPoint local =
            Assert.IsType<IPEndPoint>(
                request.LocalEndPoint);

        Assert.Equal(
            IPAddress.IPv6Loopback,
            remote.Address);

        Assert.Equal(
            IPAddress.IPv6Loopback,
            local.Address);

        Assert.Equal(
            server.Port,
            local.Port);

        Assert.NotEqual(
            server.Port,
            remote.Port);
    }

    private static async Task RequireDualStackLoopbackCapabilityAsync()
    {
        TcpListener? listener = null;

        try
        {
            listener = new TcpListener(IPAddress.IPv6Any, 0);
            listener.Server.DualMode = true;
            listener.Start();

            int port = ((IPEndPoint)listener.LocalEndpoint).Port;

            using (var ipv4Client = new TcpClient(AddressFamily.InterNetwork))
            {
                await ipv4Client.ConnectAsync(IPAddress.Loopback, port);
                using TcpClient acceptedIpv4 = await listener.AcceptTcpClientAsync();
            }

            using (var ipv6Client = new TcpClient(AddressFamily.InterNetworkV6))
            {
                await ipv6Client.ConnectAsync(IPAddress.IPv6Loopback, port);
                using TcpClient acceptedIpv6 = await listener.AcceptTcpClientAsync();
            }
        }
        catch (PlatformNotSupportedException exception)
        {
            throw SkipException.ForSkip(
                $"Dual-stack IPv4/IPv6 loopback connectivity is unavailable: {exception.Message}");
        }
        catch (NotSupportedException exception)
        {
            throw SkipException.ForSkip(
                $"Dual-stack IPv4/IPv6 loopback connectivity is unavailable: {exception.Message}");
        }
        catch (SocketException exception)
        {
            throw SkipException.ForSkip(
                $"Dual-stack IPv4/IPv6 loopback connectivity is unavailable: {exception.Message}");
        }
        finally
        {
            listener?.Stop();
        }
    }

    private sealed class TestGopherServer : IAsyncDisposable
    {
        private readonly IHost _host;
        private bool _stopped;

        private TestGopherServer(
            TestContentStore content,
            int port,
            IHost host,
            IMissionControlClient missionControlClient)
        {
            Content = content;
            Port = port;
            _host = host;
            MissionControlClient = missionControlClient;
        }

        public TestContentStore Content { get; }
        public int Port { get; }
        public IMissionControlClient MissionControlClient { get; }

        public static async Task<TestGopherServer> StartAsync(
            IMissionControlClient? missionControlClient = null,
            string? telemetryIgnoredRemoteAddress = null,
            string[]? telemetryIgnoredSelectors = null,
            int maxConcurrentConnections = 64,
            IEnumerable<IGopherPage>? pages = null,
            IGuestbookStore? guestbookStore = null,
            GuestbookOptions? guestbookOptions = null,
            string? listenAddress = null,
            bool dualMode = false,
            int responseTimeoutSeconds = 5)
        {
            if ((guestbookStore is null) != (guestbookOptions is null))
            {
                throw new ArgumentException(
                    "The guestbook store and options must be provided together.");
            }

            TestContentStore content = new();
            IPAddress parsedListenAddress = IPAddress.Parse(
                listenAddress ?? "127.0.0.1");
            int port = GetAvailablePort(parsedListenAddress, dualMode);
            HappyGopherOptions options = new()
            {
                ListenAddress = parsedListenAddress.ToString(),
                DualMode = dualMode,
                PublicHost = "127.0.0.1",
                Port = port,
                ContentRoot = content.Root,
                MaxConcurrentConnections = maxConcurrentConnections,
                MaxSelectorBytes = 4096,
                MaxInputBytes = 1024,
                RequestTimeoutSeconds = 5,
                ResponseTimeoutSeconds = responseTimeoutSeconds,
                TelemetryIgnoredRemoteAddress =
                    telemetryIgnoredRemoteAddress,
                TelemetryIgnoredSelectors =
                    telemetryIgnoredSelectors ?? []
            };
            IGopherPage[] registeredPages = pages?.ToArray() ?? [];

            missionControlClient ??= NullMissionControlClient.Instance;

            IHost? host = null;

            try
            {
                host = Host.CreateDefaultBuilder()
                    .UseDefaultServiceProvider(options =>
                    {
                        options.ValidateOnBuild = true;
                        options.ValidateScopes = true;
                    })
                    .ConfigureLogging(logging =>
                        logging.ClearProviders())
                    .ConfigureServices(services =>
                    {
                        foreach (IGopherPage page in registeredPages)
                        {
                            services.AddSingleton<IGopherPage>(page);
                        }

                        services.AddScoped<IGopherPage, HealthPage>();

                        if (guestbookStore is not null &&
                            guestbookOptions is not null)
                        {
                            services.AddSingleton<IGuestbookStore>(guestbookStore);
                            services.AddSingleton<IOptions<GuestbookOptions>>(
                                Options.Create(guestbookOptions));
                            services.AddScoped<IGopherPage, ViewGuestbookPage>();
                            services.AddScoped<IGopherPage, SignGuestbookPage>();
                        }

                        services.AddSingleton(missionControlClient);
                        services.AddSingleton<IOptions<MissionControlClientOptions>>(
                            Options.Create(new MissionControlClientOptions
                            {
                                Enabled = true
                            }));
                        services.AddSingleton<IOptions<HappyGopherOptions>>(Options.Create(options));
                        services.AddSingleton<GopherContentStore>();
                        services.AddSingleton<TelemetryService>();
                        services.AddScoped<GopherPageResolver>();
                        services.AddTcpServer<GopherConnectionHandler, HappyGopherOptions>();
                        services.AddHostedService<GopherLifecycleService>();
                    })
                    .Build();

                using CancellationTokenSource timeout =
                    new(TimeSpan.FromSeconds(5));
                await host.StartAsync(timeout.Token);

                return new TestGopherServer(
                    content,
                    port,
                    host,
                    missionControlClient);
            }
            catch
            {
                try
                {
                    host?.Dispose();
                }
                finally
                {
                    content.Dispose();
                }

                throw;
            }
        }

        public Task<string> RequestAsync(string selector) =>
            RequestAsync(selector, IPAddress.Loopback);

        public async Task<string> RequestAsync(
            string selector,
            IPAddress address) =>
            WireEncoding.GetString(
                await RequestBytesAsync(selector, address));

        public Task<byte[]> RequestBytesAsync(string selector) =>
            RequestBytesAsync(selector, IPAddress.Loopback);

        public async Task<byte[]> RequestBytesAsync(
            string selector,
            IPAddress address)
        {
            using TcpClient client = new(address.AddressFamily);
            using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
            await client.ConnectAsync(address, Port, timeout.Token);

            await using NetworkStream stream = client.GetStream();
            byte[] request = WireEncoding.GetBytes(selector + "\r\n");
            await stream.WriteAsync(request, timeout.Token);
            client.Client.Shutdown(SocketShutdown.Send);

            using MemoryStream response = new();
            byte[] buffer = new byte[4096];
            while (true)
            {
                int read = await stream.ReadAsync(buffer, timeout.Token);
                if (read == 0)
                {
                    break;
                }

                response.Write(buffer, 0, read);
            }

            return response.ToArray();
        }

        public async Task StopAsync(CancellationToken cancellationToken)
        {
            if (_stopped)
            {
                return;
            }

            _stopped = true;
            await _host.StopAsync(cancellationToken);
        }

        public async ValueTask DisposeAsync()
        {
            try
            {
                using CancellationTokenSource timeout =
                    new(TimeSpan.FromSeconds(5));
                await StopAsync(timeout.Token);
            }
            finally
            {
                try
                {
                    _host.Dispose();
                }
                finally
                {
                    Content.Dispose();
                }
            }
        }

        private static int GetAvailablePort(
            IPAddress listenAddress,
            bool dualMode)
        {
            TcpListener listener = new(listenAddress, 0);
            if (listenAddress.AddressFamily == AddressFamily.InterNetworkV6)
            {
                listener.Server.DualMode = dualMode;
            }

            listener.Start();
            int port = ((IPEndPoint)listener.LocalEndpoint).Port;
            listener.Stop();
            return port;
        }
    }
}
