using System.Net;
using Mbproxy.Admin;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Supervision;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Admin;

/// <summary>
/// Unit tests for <see cref="StatusSnapshotBuilder"/>.
/// All tests use a real in-process host with <see cref="NoopPduPipeline"/> and
/// in-memory configuration. No network I/O is required.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusSnapshotBuilderTests
{
    // ── 1. No PLCs configured → empty PLC list ────────────────────────────────

    [Fact]
    public async Task Build_NoPlcsConfigured_ReturnsEmptyPlcList()
    {
        var (host, builder) = await BuildAsync([]);
        await using var _ = new AsyncHostDispose(host);

        var result = builder.Build();

        result.Plcs.ShouldBeEmpty();
        result.Listeners.Configured.ShouldBe(0);
        result.Listeners.Bound.ShouldBe(0);
    }

    // ── 2. One PLC bound → state is "bound" ───────────────────────────────────

    [Fact]
    public async Task Build_OnePlcBound_PopulatesListenerState_Bound()
    {
        int port = PickFreePort();
        var (host, builder) = await BuildAsync([("PLC-A", port)]);
        await using var _ = new AsyncHostDispose(host);

        // Wait for the listener to bind.
        await WaitForAsync(
            () => CanConnect(port),
            TimeSpan.FromSeconds(5),
            "PLC-A listener should bind");

        var result = builder.Build();

        var plc = result.Plcs.ShouldHaveSingleItem();
        plc.Name.ShouldBe("PLC-A");
        plc.Listener.State.ShouldBe("bound");
        plc.Listener.LastBindError.ShouldBeNull();
    }

    // ── 3. PLC recovering → state + last error + attempts ────────────────────

    [Fact]
    public async Task Build_PlcRecovering_PopulatesLastBindError_AndAttempts()
    {
        // Bind the occupier on ANY so the proxy (also ANY) cannot rebind the same port.
        var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
        occupier.Server.SetSocketOption(
            System.Net.Sockets.SocketOptionLevel.Socket,
            System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
            true);
        occupier.Start();
        int port = ((IPEndPoint)occupier.LocalEndpoint).Port;

        try
        {
            var (host, builder) = await BuildAsync([("PLC-A", port)], startupWaitMs: 500);
            await using var _ = new AsyncHostDispose(host);

            // Give the supervisor time to attempt and fail (it enters Recovering state).
            await Task.Delay(300, TestContext.Current.CancellationToken);

            var result = builder.Build();

            var plc = result.Plcs.ShouldHaveSingleItem();
            plc.Listener.State.ShouldBe("recovering");
        }
        finally
        {
            occupier.Stop();
        }
    }

    // ── 4. Aggregate bound/configured ────────────────────────────────────────

    [Fact]
    public async Task Build_AggregatesListenersBoundAndConfigured()
    {
        int portA = PickFreePort();

        // Occupy portB on ANY with exclusive address use so the proxy cannot rebind it.
        var occupier = new System.Net.Sockets.TcpListener(IPAddress.Any, 0);
        occupier.Server.SetSocketOption(
            System.Net.Sockets.SocketOptionLevel.Socket,
            System.Net.Sockets.SocketOptionName.ExclusiveAddressUse,
            true);
        occupier.Start();
        int portB = ((IPEndPoint)occupier.LocalEndpoint).Port;

        try
        {
            var (host, builder) = await BuildAsync([("PLC-A", portA), ("PLC-B", portB)],
                startupWaitMs: 400);
            await using var _ = new AsyncHostDispose(host);

            await WaitForAsync(
                () => CanConnect(portA),
                TimeSpan.FromSeconds(5),
                "PLC-A should bind");

            // Give portB's supervisor time to make its first (failing) attempt.
            await Task.Delay(200, TestContext.Current.CancellationToken);

            var result = builder.Build();

            result.Listeners.Configured.ShouldBe(2);
            result.Listeners.Bound.ShouldBe(1);   // only PLC-A is bound
        }
        finally
        {
            occupier.Stop();
        }
    }

    // ── 5. Per-client snapshot populated after connection ────────────────────

    [Fact]
    public async Task Build_PerClientSnapshot_Includes_RemoteAndConnectedAt_AndPduCount()
    {
        int proxyPort = PickFreePort();

        // Start a "fake backend" listener so the multiplexer's backend-connect succeeds.
        var fakeBackend = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
        fakeBackend.Start();
        int backendPort = ((IPEndPoint)fakeBackend.LocalEndpoint).Port;

        // Track accepted sockets so we can hold them open while the test runs.
        var acceptedSockets = new System.Collections.Generic.List<System.Net.Sockets.Socket>();

        // Accept connections in the background and keep them open.
        var backendAcceptTask = Task.Run(async () =>
        {
            while (true)
            {
                try
                {
                    var accepted = await fakeBackend.AcceptSocketAsync(CancellationToken.None);
                    lock (acceptedSockets) acceptedSockets.Add(accepted);
                }
                catch { break; }
            }
        }, CancellationToken.None);

        try
        {
            var (host, builder) = await BuildAsync(
                [("PLC-A", proxyPort)],
                backendPort: backendPort);
            await using var hostDispose = new AsyncHostDispose(host);

            await WaitForAsync(
                () => CanConnect(proxyPort),
                TimeSpan.FromSeconds(5),
                "PLC-A should bind");

            // Connect a TCP client to the proxy's listen port.
            using var client = new System.Net.Sockets.TcpClient();
            await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);

            // Give the listener a moment to register the pair.
            await Task.Delay(200, TestContext.Current.CancellationToken);

            var result = builder.Build();
            var plc = result.Plcs.ShouldHaveSingleItem();
            plc.Clients.Connected.ShouldBe(1);
            var clientSnap = plc.Clients.RemoteEndpoints.ShouldHaveSingleItem();
            clientSnap.Remote.ShouldNotBeNullOrEmpty();
            // ConnectedAtUtc should be recent (within 10 s).
            (DateTimeOffset.UtcNow - clientSnap.ConnectedAtUtc).TotalSeconds.ShouldBeLessThan(10);
        }
        finally
        {
            lock (acceptedSockets)
                foreach (var s in acceptedSockets) try { s.Dispose(); } catch { }
            fakeBackend.Stop();
            try { await backendAcceptTask.WaitAsync(TimeSpan.FromSeconds(1), CancellationToken.None); } catch { }
        }
    }

    // ── 6. Service fields: uptime, version, last-reload ──────────────────────

    [Fact]
    public async Task Build_ServiceFields_IncludeUptime_Version_AndLastReload()
    {
        var (host, builder) = await BuildAsync([]);
        await using var _ = new AsyncHostDispose(host);

        var counters = host.Services.GetRequiredService<ServiceCounters>();
        var now = DateTimeOffset.UtcNow;
        counters.RecordReloadApplied(now);

        var result = builder.Build();

        result.Service.UptimeSeconds.ShouldBeGreaterThanOrEqualTo(0);
        result.Service.Version.ShouldNotBeNullOrEmpty();
        result.Service.ConfigLastReloadUtc.ShouldNotBeNull();
        result.Service.ConfigReloadCount.ShouldBe(1);
    }

    // ── 7. BuildDebug: unknown PLC → empty, disarmed snapshot ────────────────

    [Fact]
    public async Task BuildDebug_UnknownPlc_ReturnsEmptyDisarmedSnapshot()
    {
        var (host, builder) = await BuildAsync([]);
        await using var _ = new AsyncHostDispose(host);

        var debug = builder.BuildDebug("no-such-plc");

        debug.CaptureArmed.ShouldBeFalse();
        debug.Tags.ShouldBeEmpty();
    }

    // ── 8. BuildDebug: configured PLC → one row per BCD tag, no traffic ──────

    [Fact]
    public async Task BuildDebug_ConfiguredPlc_ReturnsTagRows_DisarmedByDefault()
    {
        int port = PickFreePort();
        var (host, builder) = await BuildAsync([("PLC-A", port)], bcd16Address: 1072);
        await using var _ = new AsyncHostDispose(host);

        await WaitForAsync(() => CanConnect(port), TimeSpan.FromSeconds(5), "PLC-A should bind");

        var debug = builder.BuildDebug("PLC-A");

        debug.CaptureArmed.ShouldBeFalse(); // no detail page open
        var tag = debug.Tags.ShouldHaveSingleItem();
        tag.Address.ShouldBe(1072);
        tag.Width.ShouldBe(16);
        tag.HasValue.ShouldBeFalse();
        tag.RawHex.ShouldBe("—");
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private static async Task<(IHost host, StatusSnapshotBuilder builder)> BuildAsync(
        (string name, int port)[] plcs,
        int startupWaitMs = 200,
        int backendPort = 502,
        int? bcd16Address = null)
    {
        var config = new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"] = "0", // disable admin for unit tests
        };

        if (bcd16Address is { } addr)
        {
            config["Mbproxy:BcdTags:Global:0:Address"] = addr.ToString();
            config["Mbproxy:BcdTags:Global:0:Width"]   = "16";
        }

        for (int i = 0; i < plcs.Length; i++)
        {
            config[$"Mbproxy:Plcs:{i}:Name"]       = plcs[i].name;
            config[$"Mbproxy:Plcs:{i}:ListenPort"] = plcs[i].port.ToString();
            config[$"Mbproxy:Plcs:{i}:Host"]       = "127.0.0.1";
            config[$"Mbproxy:Plcs:{i}:Port"]       = backendPort.ToString();
        }

        var hostBuilder = Host.CreateApplicationBuilder();
        hostBuilder.Configuration.AddInMemoryCollection(config);
        hostBuilder.Services.AddSerilog(
            new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
            dispose: false);
        hostBuilder.AddMbproxyOptions();
        hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();

        // Register ProxyWorker as singleton so StatusSnapshotBuilder can resolve it by type.
        hostBuilder.Services.AddSingleton<ProxyWorker>();
        hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());

        // Admin support singletons (no AdminEndpointHost — keep unit tests lean).
        hostBuilder.Services.AddSingleton<AssemblyVersionAccessor>();
        hostBuilder.Services.AddSingleton<StatusSnapshotBuilder>();

        var host = hostBuilder.Build();
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
        await host.StartAsync(startCts.Token);
        await Task.Delay(startupWaitMs, TestContext.Current.CancellationToken);

        var snapshotBuilder = host.Services.GetRequiredService<StatusSnapshotBuilder>();
        return (host, snapshotBuilder);
    }

    private static int PickFreePort()
    {
        var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
        l.Start();
        int port = ((IPEndPoint)l.LocalEndpoint).Port;
        l.Stop();
        return port;
    }

    private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout, string msg)
    {
        using var cts = new CancellationTokenSource(timeout);
        while (!predicate() && !cts.IsCancellationRequested)
            await Task.Delay(50, cts.Token).ConfigureAwait(false);
        predicate().ShouldBeTrue(msg);
    }

    private static bool CanConnect(int port)
    {
        try { using var c = new System.Net.Sockets.TcpClient(); c.Connect("127.0.0.1", port); return true; }
        catch { return false; }
    }

    private sealed class AsyncHostDispose : IAsyncDisposable
    {
        private readonly IHost _host;
        public AsyncHostDispose(IHost host) => _host = host;
        public async ValueTask DisposeAsync()
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            try { await _host.StopAsync(cts.Token); } catch { }
            _host.Dispose();
        }
    }
}
