using Mbproxy.Admin;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Serilog;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Admin;

/// <summary>
/// Unit tests for <see cref="StatusBroadcaster"/>'s push-cycle logic — fleet always
/// pushed, per-PLC pushed only for PLCs with a detail-page subscriber, and every
/// capture disarmed on stop. The SignalR sink is faked; a real
/// <see cref="StatusSnapshotBuilder"/> is resolved from a minimal in-process host.
/// </summary>
[Trait("Category", "Unit")]
public sealed class StatusBroadcasterTests
{
    private sealed record Harness(
        IHost Host,
        StatusBroadcaster Broadcaster,
        FakeStatusPushSink Sink,
        StatusSnapshotBuilder Builder,
        TagCaptureRegistry Registry,
        PlcSubscriptionTracker Tracker) : IAsyncDisposable
    {
        public async ValueTask DisposeAsync()
        {
            await Broadcaster.DisposeAsync();
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            try { await Host.StopAsync(cts.Token); } catch { }
            Host.Dispose();
        }
    }

    private static async Task<Harness> BuildAsync(IStatusPushSink? sinkOverride = null)
    {
        var hostBuilder = Host.CreateApplicationBuilder();
        hostBuilder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"] = "0",
            // Fast tick so the LoopAsync test observes several cycles quickly.
            ["Mbproxy:AdminPushIntervalMs"] = "100",
        });
        hostBuilder.Services.AddSerilog(
            new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(), dispose: false);
        hostBuilder.AddMbproxyOptions();
        hostBuilder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
        hostBuilder.Services.AddSingleton<ProxyWorker>();
        hostBuilder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
        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);

        var builder  = host.Services.GetRequiredService<StatusSnapshotBuilder>();
        var registry = host.Services.GetRequiredService<TagCaptureRegistry>();
        var options  = host.Services.GetRequiredService<IOptionsMonitor<MbproxyOptions>>();
        var tracker  = new PlcSubscriptionTracker();
        var sink     = new FakeStatusPushSink();

        var broadcaster = new StatusBroadcaster(
            sinkOverride ?? sink, builder, tracker, registry, options, NullLogger.Instance);

        return new Harness(host, broadcaster, sink, builder, registry, tracker);
    }

    [Fact]
    public async Task PushOnce_AlwaysPushesFleet()
    {
        await using var h = await BuildAsync();

        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);

        h.Sink.FleetPushes.Count.ShouldBe(1);
    }

    [Fact]
    public async Task PushOnce_NoActivePlcs_SkipsPerPlcPush()
    {
        await using var h = await BuildAsync();

        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);

        h.Sink.PlcPushes.ShouldBeEmpty();
    }

    [Fact]
    public async Task PushOnce_ActivePlc_PushesDetailWithDebugSnapshot()
    {
        await using var h = await BuildAsync();
        h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
        h.Tracker.SubscribePlc("conn-1", "tab-1", "plc-x");

        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);

        var push = h.Sink.PlcPushes.ShouldHaveSingleItem();
        push.Plc.ShouldBe("plc-x");
        push.Detail.Debug.ShouldNotBeNull();
    }

    [Fact]
    public async Task PushOnce_ReconcilesCaptureArmState_FromActiveViewers()
    {
        await using var h = await BuildAsync();
        h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);

        // No viewer yet — a push must leave the capture disarmed.
        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
        h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
        capture.IsArmed.ShouldBeFalse("no detail page open — capture stays disarmed");

        // A viewer opens the detail page — the next push arms the capture.
        h.Tracker.SubscribePlc("conn-1", "tab-1", "plc-x");
        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
        capture.IsArmed.ShouldBeTrue("the broadcaster reconciles the capture armed for a viewed PLC");

        // The viewer leaves — the next push disarms it again.
        h.Tracker.RemoveConnection("conn-1");
        await h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken);
        capture.IsArmed.ShouldBeFalse("the broadcaster disarms a capture once its last viewer leaves");
    }

    [Fact]
    public async Task PushOnce_SinkThrowsNonCancellation_FailureIsSwallowed()
    {
        // A SignalR transport fault on a push must not escape PushOnceAsync — the loop
        // has to survive it and retry on the next cycle.
        var throwing = new ThrowingStatusPushSink(() => new InvalidOperationException("boom"));
        await using var h = await BuildAsync(throwing);

        await Should.NotThrowAsync(
            () => h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task PushOnce_SinkThrowsOperationCanceled_Propagates()
    {
        // The catch filters are `when (ex is not OperationCanceledException)` — a genuine
        // cancellation must propagate so the loop unwinds at shutdown instead of being
        // swallowed and retried.
        var throwing = new ThrowingStatusPushSink(() => new OperationCanceledException());
        await using var h = await BuildAsync(throwing);

        await Should.ThrowAsync<OperationCanceledException>(
            () => h.Broadcaster.PushOnceAsync(TestContext.Current.CancellationToken));
    }

    [Fact]
    public async Task StopAsync_DisarmsEveryCapture()
    {
        await using var h = await BuildAsync();
        h.Registry.GetOrCreate("plc-x", BcdTagMap.Empty);
        h.Registry.ReconcileArmed(["plc-x"]);

        await h.Broadcaster.StopAsync();

        h.Registry.TryGet("plc-x", out var capture).ShouldBeTrue();
        capture.IsArmed.ShouldBeFalse();
    }

    [Fact]
    public async Task Loop_PushesRepeatedly_ThenStopsAfterStopAsync()
    {
        await using var h = await BuildAsync();

        h.Broadcaster.Start();

        // The harness runs at AdminPushIntervalMs = 100 ms; wait (generously) for the
        // background loop to complete several cycles.
        var deadline = DateTime.UtcNow.AddSeconds(10);
        while (h.Sink.FleetPushes.Count < 3 && DateTime.UtcNow < deadline)
            await Task.Delay(50, TestContext.Current.CancellationToken);

        h.Sink.FleetPushes.Count.ShouldBeGreaterThanOrEqualTo(3,
            "the background loop must push the fleet snapshot every interval");

        await h.Broadcaster.StopAsync();
        int afterStop = h.Sink.FleetPushes.Count;
        await Task.Delay(400, TestContext.Current.CancellationToken);
        h.Sink.FleetPushes.Count.ShouldBe(afterStop, "no pushes may occur after StopAsync");
    }
}
