using System.Reactive.Linq;
using Microsoft.Extensions.Logging.Abstractions;
using DragonISO.Engine.Controller;
using DragonISO.Engine.Domain;
using DragonISO.Engine.Interop;
using DragonISO.Engine.Persistence;
using DragonISO.Engine.Pipeline;
using DragonISO.Engine.Tests.Fakes;

namespace DragonISO.Engine.Tests.Controller;

public class IsoControllerTests : IDisposable
{
    private readonly string _dir;
    private readonly ConfigStore _store;
    private readonly FakeNdiInterop _interop;
    private readonly NdiRuntimeProbe _probeMatch;
    private readonly NdiRuntimeProbe _probeMismatch;
    private readonly List<IsoPipelineConfig> _factoryCalls = new();
    private readonly Dictionary<Guid, TaskCompletionSource> _pipelineBlockers = new();

    public IsoControllerTests()
    {
        _dir = Path.Combine(Path.GetTempPath(), $"Dragon-ISO-controller-{Guid.NewGuid():N}");
        Directory.CreateDirectory(_dir);
        _store = new ConfigStore(Path.Combine(_dir, "config.json"), NullLogger<ConfigStore>.Instance);
        _interop = new FakeNdiInterop { RuntimeVersion = "6.0.0" };
        _probeMatch = new NdiRuntimeProbe(_interop, expectedVersion: "6.0.0");
        _probeMismatch = new NdiRuntimeProbe(_interop, expectedVersion: "9.9.9");
    }

    public void Dispose() => Directory.Delete(_dir, recursive: true);

    private IsoPipeline TestPipelineFactory(IsoPipelineConfig config)
    {
        _factoryCalls.Add(config);
        var blocker = new TaskCompletionSource();
        _pipelineBlockers[config.ParticipantId] = blocker;
        var backoff = new ExponentialBackoff(maxAttempts: 3, initial: TimeSpan.FromMilliseconds(1), cap: TimeSpan.FromMilliseconds(5));
        return new IsoPipeline(
            config.ParticipantId,
            ct => blocker.Task.WaitAsync(ct),
            backoff,
            (_, _) => Task.CompletedTask,
            NullLoggerFactory.Instance);
    }

    private IsoController NewController(NdiRuntimeProbe? probe = null) =>
        new(
            _interop,
            TestPipelineFactory,
            _store,
            probe ?? _probeMatch,
            NullLoggerFactory.Instance,
            renameWindow: TimeSpan.FromSeconds(5),
            discoveryInterval: TimeSpan.FromMilliseconds(20));

    [Fact]
    public async Task DiscoveredParticipant_AppearsInParticipantsObservable()
    {
        await using var controller = NewController();
        var seenLists = new List<IReadOnlyList<Participant>>();
        using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));

        await controller.StartAsync(CancellationToken.None);
        _interop.Sources.Add("PC1 (Teams - Jane)");

        // Wait for discovery to pick it up
        var deadline = DateTime.UtcNow.AddSeconds(2);
        while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
            await Task.Delay(20);

        seenLists.Last().Should().HaveCount(1);
        seenLists.Last()[0].DisplayName.Should().Be("Jane");
    }

    [Fact]
    public async Task EnableIsoAsync_CreatesAndStartsPipeline()
    {
        await using var controller = NewController();
        await controller.StartAsync(CancellationToken.None);

        _interop.Sources.Add("PC1 (Teams - Jane)");
        var pid = await WaitForFirstParticipantAsync(controller);

        await controller.EnableIsoAsync(pid, customName: "Dragon-ISO_JANE", CancellationToken.None);

        _factoryCalls.Should().HaveCount(1);
        _factoryCalls[0].OutputName.Should().Be("Dragon-ISO_JANE");
        _factoryCalls[0].SourceName.Should().Be("PC1 (Teams - Jane)");
    }

    [Fact]
    public async Task DisableIsoAsync_StopsPipeline_AndRemovesIt()
    {
        await using var controller = NewController();
        await controller.StartAsync(CancellationToken.None);

        _interop.Sources.Add("PC1 (Teams - Jane)");
        var pid = await WaitForFirstParticipantAsync(controller);
        await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);

        // Unblock the pipeline so StopAsync can complete
        _pipelineBlockers[pid].SetResult();
        await controller.DisableIsoAsync(pid, CancellationToken.None);

        // Re-enable should now create a new pipeline
        await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);
        _factoryCalls.Should().HaveCount(2);
    }

    [Fact]
    public async Task SetGlobalSettingsAsync_PersistsToConfigStore()
    {
        await using var controller = NewController();
        var newSettings = new FrameProcessingSettings(
            TargetFramerate.Fps59_94, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto);

        await controller.SetGlobalSettingsAsync(newSettings, CancellationToken.None);

        controller.GlobalSettings.Should().Be(newSettings);
        _store.Load().Global.Should().Be(newSettings);
    }

    [Fact]
    public async Task Constructor_PassesDiscoveryGroupsToFinder()
    {
        // Pre-populate the config store with a discovery-groups setting; the controller
        // reads it on construction and feeds it into the finder.
        _store.Save(new EngineConfig(
            FrameProcessingSettings.Default,
            Array.Empty<IsoAssignment>(),
            new NdiGroupSettings(DiscoveryGroups: "Dragon-ISO-input", OutputGroups: null)));

        await using var controller = NewController();

        _interop.LastFinderGroups.Should().Be("Dragon-ISO-input");
        controller.GroupSettings.DiscoveryGroups.Should().Be("Dragon-ISO-input");
    }

    [Fact]
    public async Task EnableIsoAsync_PassesOutputGroupsToPipelineConfig()
    {
        _store.Save(new EngineConfig(
            FrameProcessingSettings.Default,
            Array.Empty<IsoAssignment>(),
            new NdiGroupSettings(DiscoveryGroups: null, OutputGroups: "Public,producers")));

        await using var controller = NewController();
        await controller.StartAsync(CancellationToken.None);

        _interop.Sources.Add("PC1 (Teams - Jane)");
        var pid = await WaitForFirstParticipantAsync(controller);

        await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);

        _factoryCalls.Should().HaveCount(1);
        _factoryCalls[0].OutputGroups.Should().Be("Public,producers");
    }

    [Fact]
    public async Task SetGroupSettingsAsync_PersistsToConfigStore()
    {
        await using var controller = NewController();
        var newGroups = new NdiGroupSettings(DiscoveryGroups: "Dragon-ISO-input", OutputGroups: "Public");

        await controller.SetGroupSettingsAsync(newGroups, CancellationToken.None);

        controller.GroupSettings.Should().Be(newGroups);
        _store.Load().GroupsOrDefault.Should().Be(newGroups);
    }

    [Fact]
    public async Task StartAsync_RuntimeProbeMismatch_RaisesAlert()
    {
        await using var controller = NewController(probe: _probeMismatch);
        var alerts = new List<EngineAlert>();
        using var sub = controller.Alerts.Subscribe(alerts.Add);

        await controller.StartAsync(CancellationToken.None);

        alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
    }

    [Fact]
    public async Task SetRecording_TogglesEnabledAndStoresDirectory()
    {
        await using var controller = NewController();

        controller.RecordingEnabled.Should().BeFalse();
        controller.RecordingDirectory.Should().BeNull();

        controller.SetRecording(enabled: true, outputDirectory: @"D:\Recordings\Show1");

        controller.RecordingEnabled.Should().BeTrue();
        controller.RecordingDirectory.Should().Be(@"D:\Recordings\Show1");

        controller.SetRecording(enabled: false, outputDirectory: null);

        controller.RecordingEnabled.Should().BeFalse();
        controller.RecordingDirectory.Should().BeNull();
    }

    [Fact]
    public async Task AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders()
    {
        // No pipelines have ever started â†’ no recorders are attached.
        // AddRecordingMarker must not throw on the empty-recorder path
        // (the UI Ctrl+M binding fires regardless of recording state).
        await using var controller = NewController();

        var act = () => controller.AddRecordingMarker("test marker");

        act.Should().NotThrow();
    }

    [Fact]
    public async Task RefreshDiscovery_SetsRefreshFlagOnDiscoveryService()
    {
        // RefreshDiscovery is a fire-and-forget that just sets a flag
        // the discovery loop honours on its next tick. We exercise it
        // and verify the loop subsequently re-emits the current source
        // set as freshly-added (which is the observable contract).
        await using var controller = NewController();
        var seenLists = new List<IReadOnlyList<Participant>>();
        using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));

        await controller.StartAsync(CancellationToken.None);
        _interop.Sources.Add("PC1 (Teams - Jane)");

        var deadline = DateTime.UtcNow.AddSeconds(2);
        while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
            await Task.Delay(20);
        seenLists.Last().Should().HaveCount(1);

        var emitsBefore = seenLists.Count;

        // Trigger a refresh â€” the discovery loop should re-emit. We
        // don't care exactly how many emissions land, just that the
        // observable kept producing rather than stalling.
        controller.RefreshDiscovery();

        var refreshDeadline = DateTime.UtcNow.AddSeconds(2);
        while (seenLists.Count <= emitsBefore && DateTime.UtcNow < refreshDeadline)
            await Task.Delay(20);

        seenLists.Count.Should().BeGreaterThan(emitsBefore,
            "the refresh flag should drive a re-emission within the discovery interval");
    }

    private static async Task<Guid> WaitForFirstParticipantAsync(IsoController controller)
    {
        var tcs = new TaskCompletionSource<Guid>();
        using var sub = controller.Participants
            .Where(p => p.Count > 0)
            .Subscribe(p => tcs.TrySetResult(p[0].Id));
        return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(3));
    }
}
