using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;

namespace ytLive.Tests;

/// <summary>
/// The live frame producer (TASK 4 ship step 5): composites the active scene at
/// the tier's FPS and paces frames into the encoder. The integration test drives
/// the full lifecycle against fakes — real SceneCompositor + real FramePump, fake
/// IFfmpegEncoder — proving the composite frame actually reaches the encoder and
/// that stop tears the pump down cleanly. The units pin the failure edges: the
/// no-URL skip (the TASK 5 seam), re-entrancy, and encoder death.
/// </summary>
public class FramePumpTests
{
    private sealed class FakeEncoder : IFfmpegEncoder
    {
        public readonly List<VideoFrame> Frames = new();
        public int StartCount;
        public int StopCount;
        public bool Disposed;
        public EncoderOptions? LastOptions;
        public Exception? StartError;
        public TaskCompletionSource FrameArrived = new(TaskCreationOptions.RunContinuationsAsynchronously);

        public event EventHandler<StreamHealth>? HealthUpdated;
        public event EventHandler<string>? ProcessFailed;

        public Task StartAsync(EncoderOptions options, CancellationToken cancellationToken = default)
        {
            StartCount++;
            LastOptions = options;
            if (StartError != null) throw StartError;
            return Task.CompletedTask;
        }

        public Task SubmitFrameAsync(VideoFrame frame, CancellationToken cancellationToken = default)
        {
            Frames.Add(frame);
            FrameArrived.TrySetResult();
            return Task.CompletedTask;
        }

        public Task StopAsync(CancellationToken cancellationToken = default)
        {
            StopCount++;
            return Task.CompletedTask;
        }

        public void Dispose() => Disposed = true;

        public void RaiseProcessFailed(string message) => ProcessFailed?.Invoke(this, message);

        public void RaiseHealth(StreamHealth health) => HealthUpdated?.Invoke(this, health);
    }

    private static Scene BackdropScene()
    {
        var scene = new Scene { Name = "Live" };
        scene.Elements.Add(new Source { Type = SourceType.DisplayCapture, IsBackdrop = true, CaptureKey = "monitor:0" });
        return scene;
    }

    private static FramePump NewPump(FakeEncoder encoder, Func<EncoderOptions?>? options = null,
        Func<Scene?>? scene = null, Func<SceneElement, VideoFrame?>? resolve = null,
        List<string>? log = null,
        Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null)
    {
        return new FramePump(
            sceneProvider: scene ?? (() => BackdropScene()),
            frameResolver: resolve ?? (_ => null),
            compositorOptions: () => new CompositorOptions
            {
                SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 64, SourceRectHeight = 48,
                OutputWidth = 64, OutputHeight = 48,
            },
            encoderOptions: options ?? (() => new EncoderOptions
            {
                RtmpUrl = "rtmp://a.rtmp.youtube.com/live2/abc",
                Width = 64, Height = 48, Fps = 60,
            }),
            encoderFactory: () => encoder,
            log: log != null ? m => log.Add(m) : null,
            pacingDelay: async (_, _) => await Task.Yield(), // deterministic: no real waits
            socialBar: socialBar);
    }

    private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
    {
        var i = (y * frame.Width + x) * 4;
        var br = frame.BgraPixels[i + 2];
        var bg = frame.BgraPixels[i + 1];
        var bb = frame.BgraPixels[i];
        Assert.True(
            Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
            $"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
    }

    [Fact]
    public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
    {
        var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
        var encoder = new FakeEncoder();
        using var pump = NewPump(encoder,
            resolve: e => e is Source { IsBackdrop: true } ? red : null);

        await pump.StartAsync();
        Assert.True(pump.IsRunning);
        Assert.Equal(1, encoder.StartCount);

        await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.NotEmpty(encoder.Frames);
        var frame = encoder.Frames[0];
        Assert.Equal(64, frame.Width);
        Assert.Equal(48, frame.Height);
        AssertColor(frame, 0, 0, 255, 0, 0); // the backdrop really was composited in

        await pump.StopAsync();
        Assert.False(pump.IsRunning);
        Assert.Equal(1, encoder.StopCount);
        Assert.True(encoder.Disposed);
    }

    [Fact]
    public async Task Start_WithoutRtmpUrl_SkipsEncoder()
    {
        var encoder = new FakeEncoder();
        var log = new List<string>();
        using var pump = NewPump(encoder, options: () => null, log: log);

        await pump.StartAsync();

        Assert.False(pump.IsRunning);
        Assert.Equal(0, encoder.StartCount);
        Assert.Contains(log, m => m.Contains("RTMP"));

        await pump.StopAsync(); // no-op after a skipped start
        Assert.Equal(0, encoder.StopCount);
    }

    [Fact]
    public async Task Start_WhileRunning_IsNoop()
    {
        var encoder = new FakeEncoder();
        using var pump = NewPump(encoder);

        await pump.StartAsync();
        await pump.StartAsync();

        Assert.Equal(1, encoder.StartCount);
    }

    [Fact]
    public async Task Stop_WithoutStart_IsNoop()
    {
        var encoder = new FakeEncoder();
        using var pump = NewPump(encoder);

        await pump.StopAsync();

        Assert.Equal(0, encoder.StopCount);
        Assert.False(pump.IsRunning);
    }

    [Fact]
    public async Task Start_WithSocialBarSeam_PlacesBarAtTopThenBottomEdge()
    {
        var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
        var bar = new VideoFrame(64, 8, new byte[64 * 8 * 4]);
        Array.Fill(bar.BgraPixels, (byte)255); // opaque white strip

        var encoder = new FakeEncoder();
        var position = SocialBarPosition.Top;
        using var pump = NewPump(encoder,
            resolve: e => e is Source { IsBackdrop: true } ? red : null,
            socialBar: () => (bar, position));

        await pump.StartAsync();
        await encoder.FrameArrived.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.NotEmpty(encoder.Frames);
        AssertColor(encoder.Frames[0], 0, 0, 255, 255, 255); // bar at the top edge

        // Flip to Bottom: the pump re-reads the seam each frame, so a later frame
        // lands the bar at the bottom edge (SourceRectHeight - bar height) and the
        // top corner clears back to backdrop.
        position = SocialBarPosition.Bottom;
        VideoFrame? flipped = null;
        var deadline = DateTime.UtcNow.AddSeconds(5);
        while (DateTime.UtcNow < deadline && flipped == null)
        {
            for (var i = 1; i < encoder.Frames.Count; i++)
            {
                var f = encoder.Frames[i];
                if (f.BgraPixels[2] == 255 && f.BgraPixels[1] == 0 && f.BgraPixels[0] == 0)
                {
                    flipped = f;
                    break;
                }
            }
            if (flipped == null) await Task.Delay(10);
        }
        Assert.NotNull(flipped);
        AssertColor(flipped!, 0, 47, 255, 255, 255); // bar sits on the bottom edge

        await pump.StopAsync();
    }

    [Fact]
    public async Task Start_EncoderThrows_RaisesFailed_AndDisposes()
    {
        var encoder = new FakeEncoder { StartError = new InvalidOperationException("access denied") };
        var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var pump = NewPump(encoder);
        pump.Failed += (_, m) => failed.TrySetResult(m);

        await pump.StartAsync();

        Assert.False(pump.IsRunning);
        var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Contains("access denied", message);
        Assert.True(encoder.Disposed);
    }

    [Fact]
    public async Task ProcessDeath_StopsPump_AndRaisesFailed()
    {
        var encoder = new FakeEncoder();
        var failed = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var pump = NewPump(encoder);
        pump.Failed += (_, m) => failed.TrySetResult(m);

        await pump.StartAsync();
        encoder.RaiseProcessFailed("FFmpeg exited with code 1");

        var message = await failed.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Contains("1", message);

        // The pump self-stops (fire-and-forget); poll the definitive teardown
        // marker — the encoder's disposal — rather than the IsRunning flag, which
        // StopAsync clears before the loop has fully drained.
        var deadline = DateTime.UtcNow.AddSeconds(5);
        while (!encoder.Disposed && DateTime.UtcNow < deadline)
            await Task.Delay(10);
        Assert.True(encoder.Disposed);
        Assert.False(pump.IsRunning);
    }

    [Fact]
    public async Task HealthUpdated_ForwardsEncoderHealth()
    {
        var encoder = new FakeEncoder();
        var health = new TaskCompletionSource<StreamHealth>(TaskCreationOptions.RunContinuationsAsynchronously);
        using var pump = NewPump(encoder);
        pump.HealthUpdated += (_, h) => health.TrySetResult(h);

        await pump.StartAsync();
        encoder.RaiseHealth(new StreamHealth { Status = StreamStatus.Streaming, FPS = 59.9 });

        var h = await health.Task.WaitAsync(TimeSpan.FromSeconds(5));
        Assert.Equal(StreamStatus.Streaming, h.Status);
        Assert.Equal(59.9, h.FPS, 1);
    }
}
