using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
using DragonISO.Engine.Discovery;
using DragonISO.Engine.Tests.Fakes;

namespace DragonISO.Engine.Tests.Discovery;

public class NdiDiscoveryServiceTests
{
    [Fact]
    public void PollOnce_AddsNewParticipantSources_AndIgnoresMalformedStrings()
    {
        var interop = new FakeNdiInterop();
        interop.Sources.Add("PC1 (Teams - Jane)");
        interop.Sources.Add("PC1 (Teams)");
        interop.Sources.Add("Just A Camera");
        interop.Sources.Add("BAD (Teams - )");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();

        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);

        svc.PollOnce();

        var emitted = DrainChannel(channel.Reader);
        emitted.OfType<DiscoveryEvent.Added>().Select(a => a.Source.FullName)
            .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)", "PC1 (Teams)" });
    }

    [Fact]
    public void PollOnce_EmitsRemoved_WhenSourceDisappears()
    {
        var interop = new FakeNdiInterop();
        interop.Sources.Add("PC1 (Teams - Jane)");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();

        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);
        svc.PollOnce();
        DrainChannel(channel.Reader);

        interop.Sources.Clear();
        svc.PollOnce();

        var emitted = DrainChannel(channel.Reader);
        emitted.OfType<DiscoveryEvent.Removed>().Select(r => r.Source.FullName)
            .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" });
    }

    [Fact]
    public void PollOnce_NoChange_EmitsNothing()
    {
        var interop = new FakeNdiInterop();
        interop.Sources.Add("PC1 (Teams - Jane)");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();

        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);
        svc.PollOnce(); DrainChannel(channel.Reader);

        svc.PollOnce();

        DrainChannel(channel.Reader).Should().BeEmpty();
    }

    private static List<DiscoveryEvent> DrainChannel(ChannelReader<DiscoveryEvent> reader)
    {
        var list = new List<DiscoveryEvent>();
        while (reader.TryRead(out var ev)) list.Add(ev);
        return list;
    }

    // ============================================================
    // RebuildFinder â€” swap-then-dispose, failure keeps the old finder
    // ============================================================

    [Fact]
    public void RebuildFinder_WhenCreateFails_KeepsExistingFinder_AndKeepsPolling()
    {
        // First finder (construction) succeeds; the rebuild's CreateFinder throws.
        var interop = new FakeNdiInterop
        {
            CreateFinderHook = ordinal =>
            {
                if (ordinal >= 2) throw new InvalidOperationException("runtime refused a new finder");
            },
        };
        interop.Sources.Add("PC1 (Teams - Jane)");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();
        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);

        // Prime the seen-set.
        svc.PollOnce();
        DrainChannel(channel.Reader);

        // Rebuild fails â€” must return false and must NOT dispose the live finder.
        svc.RebuildFinder("test: simulated failure").Should().BeFalse();
        interop.FinderCreatedCount.Should().Be(2); // initial + the failed attempt

        // The incumbent finder is still alive: PollOnce reads it without throwing.
        // Pre-fix this threw ObjectDisposedException because the finder had been
        // disposed before the failing CreateFinder call.
        var poll = () => svc.PollOnce();
        poll.Should().NotThrow();
    }

    [Fact]
    public void RebuildFinder_FailureKeepsSeenSet_NoSpuriousReAdd()
    {
        var interop = new FakeNdiInterop
        {
            CreateFinderHook = ordinal =>
            {
                if (ordinal >= 2) throw new InvalidOperationException("boom");
            },
        };
        interop.Sources.Add("PC1 (Teams - Jane)");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();
        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);

        svc.PollOnce();
        DrainChannel(channel.Reader); // consume the initial Added

        svc.RebuildFinder("test: simulated failure").Should().BeFalse();
        svc.PollOnce();

        // Seen-set was preserved across the failed rebuild, so the already-known
        // source must NOT re-fire as Added.
        DrainChannel(channel.Reader).Should().BeEmpty();
    }

    [Fact]
    public void RebuildFinder_OnSuccess_SwapsFinder_AndReEmitsKnownSources()
    {
        var interop = new FakeNdiInterop();
        interop.Sources.Add("PC1 (Teams - Jane)");
        var channel = Channel.CreateUnbounded<DiscoveryEvent>();
        var svc = new NdiDiscoveryService(interop, channel.Writer, NullLogger<NdiDiscoveryService>.Instance);

        svc.PollOnce();
        DrainChannel(channel.Reader); // consume initial Added

        svc.RebuildFinder("test: success").Should().BeTrue();
        interop.FinderCreatedCount.Should().Be(2);

        // Seen-set was cleared on success, so the still-visible source re-fires.
        svc.PollOnce();
        DrainChannel(channel.Reader).OfType<DiscoveryEvent.Added>()
            .Select(a => a.Source.FullName)
            .Should().BeEquivalentTo(new[] { "PC1 (Teams - Jane)" });
    }

    // ============================================================
    // ShouldAutoRebuild â€” pure function gating the auto-heal path
    // ============================================================
    //
    // Two rules under test:
    //   (a) Never seen a source AND sinceStart>5s AND sinceLastRebuild>5s -> rebuild
    //   (b) Used to see sources, now empty AND sinceLastSeen>15s AND sinceLastRebuild>10s -> rebuild
    //
    // Both rules back off to avoid churn during legitimate empty periods.

    [Fact]
    public void ShouldAutoRebuild_NeverSeenSource_BeforeWarmup_ReturnsNull()
    {
        // 3s after startup is well inside the "give cold start a chance" window.
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromSeconds(3),
            sinceLastSeen: null,
            sinceLastRebuild: TimeSpan.FromSeconds(99))
            .Should().BeNull();
    }

    [Fact]
    public void ShouldAutoRebuild_NeverSeenSource_AfterWarmup_TriggersRebuild()
    {
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromSeconds(6),
            sinceLastSeen: null,
            sinceLastRebuild: TimeSpan.FromSeconds(6))
            .Should().Contain("never saw a source");
    }

    [Fact]
    public void ShouldAutoRebuild_NeverSeenSource_RecentRebuild_HoldsOff()
    {
        // sinceStart qualifies, but the last rebuild was 2s ago â€” back off.
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromSeconds(20),
            sinceLastSeen: null,
            sinceLastRebuild: TimeSpan.FromSeconds(2))
            .Should().BeNull();
    }

    [Fact]
    public void ShouldAutoRebuild_HadSources_NowEmpty_LongAgo_TriggersRebuild()
    {
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromMinutes(5),
            sinceLastSeen: TimeSpan.FromSeconds(20),
            sinceLastRebuild: TimeSpan.FromSeconds(30))
            .Should().Contain("source set went empty");
    }

    [Fact]
    public void ShouldAutoRebuild_HadSources_NowEmpty_Recently_HoldsOff()
    {
        // 10s since last source seen â€” still inside the 15s grace window.
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromMinutes(5),
            sinceLastSeen: TimeSpan.FromSeconds(10),
            sinceLastRebuild: TimeSpan.FromSeconds(30))
            .Should().BeNull();
    }

    [Fact]
    public void ShouldAutoRebuild_HadSources_NowEmpty_RecentRebuild_HoldsOff()
    {
        // Grace window expired, but we just rebuilt 8s ago â€” back off.
        NdiDiscoveryService.ShouldAutoRebuild(
            sinceStart: TimeSpan.FromMinutes(5),
            sinceLastSeen: TimeSpan.FromSeconds(30),
            sinceLastRebuild: TimeSpan.FromSeconds(8))
            .Should().BeNull();
    }
}
