using System.Threading;
using ClaudeDo.Ui.Services;

namespace ClaudeDo.Ui.Tests.Services;

public class OperationStatusTests
{
    // Minimal hand-rolled fake: TimeProvider's default CreateTimer schedules against the real
    // wall clock regardless of a GetUtcNow() override, so a usable fake must implement its own
    // timer queue and fire callbacks synchronously from Advance().
    private sealed class FakeTimeProvider : TimeProvider
    {
        private readonly List<FakeTimer> _timers = new();
        private DateTimeOffset _utcNow;

        public FakeTimeProvider(DateTimeOffset start) => _utcNow = start;

        public override DateTimeOffset GetUtcNow() => _utcNow;

        public void Advance(TimeSpan by)
        {
            var target = _utcNow + by;
            while (true)
            {
                FakeTimer? next = null;
                foreach (var timer in _timers)
                {
                    if (timer.NextDue is not { } due || due > target) continue;
                    if (next is null || due < next.NextDue) next = timer;
                }
                if (next is null) break;
                _utcNow = next.NextDue!.Value;
                next.Fire();
            }
            _utcNow = target;
        }

        public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
        {
            var timer = new FakeTimer(this, callback, state, dueTime, period);
            _timers.Add(timer);
            return timer;
        }

        private sealed class FakeTimer : ITimer
        {
            private readonly FakeTimeProvider _owner;
            private readonly TimerCallback _callback;
            private readonly object? _state;
            private TimeSpan _period;

            public DateTimeOffset? NextDue { get; private set; }

            public FakeTimer(FakeTimeProvider owner, TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
            {
                _owner = owner;
                _callback = callback;
                _state = state;
                _period = period;
                NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : owner._utcNow + dueTime;
            }

            public void Fire()
            {
                _callback(_state);
                NextDue = _period <= TimeSpan.Zero || _period == Timeout.InfiniteTimeSpan
                    ? null
                    : NextDue + _period;
            }

            public bool Change(TimeSpan dueTime, TimeSpan period)
            {
                _period = period;
                NextDue = dueTime == Timeout.InfiniteTimeSpan ? null : _owner._utcNow + dueTime;
                return true;
            }

            public void Dispose() => _owner._timers.Remove(this);

            public ValueTask DisposeAsync()
            {
                Dispose();
                return ValueTask.CompletedTask;
            }
        }
    }

    // Production posts the threadpool timer callback via Dispatcher.UIThread.Post; tests run
    // without a bootstrapped Avalonia dispatcher, so the callback is invoked synchronously instead.
    private static OperationStatus Create(FakeTimeProvider time) => new(time, action => action());

    [Fact]
    public void Begin_sets_IsRunning_immediately_and_ShowIndicator_after_grace_period()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        using var op = status.Begin("Working…");
        Assert.True(status.IsRunning);
        Assert.False(status.ShowIndicator);

        time.Advance(TimeSpan.FromMilliseconds(300));
        Assert.True(status.ShowIndicator);
    }

    [Fact]
    public void ShowIndicator_stays_false_when_the_operation_ends_before_grace_period()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        var op = status.Begin("Working…");
        time.Advance(TimeSpan.FromMilliseconds(200));
        op.Dispose();
        time.Advance(TimeSpan.FromMilliseconds(200)); // past the original 300ms grace mark

        Assert.False(status.ShowIndicator);
        Assert.False(status.IsRunning);
    }

    [Fact]
    public void IsStalled_measures_from_the_last_Report_not_from_Begin()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        using var op = status.Begin("Working…");
        time.Advance(TimeSpan.FromSeconds(50));
        Assert.False(status.IsStalled);

        status.Report("Still working…");
        time.Advance(TimeSpan.FromSeconds(59)); // 109s total, only 59s since the Report
        Assert.False(status.IsStalled);

        time.Advance(TimeSpan.FromSeconds(2)); // 111s total, 61s since the Report
        Assert.True(status.IsStalled);
    }

    [Fact]
    public void No_report_at_all_stalls_60_seconds_after_Begin()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        using var op = status.Begin("Working…");
        time.Advance(TimeSpan.FromSeconds(59));
        Assert.False(status.IsStalled);

        time.Advance(TimeSpan.FromSeconds(2));
        Assert.True(status.IsStalled);
    }

    [Fact]
    public void Elapsed_is_formatted_as_mm_ss()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        using var op = status.Begin("Working…");
        time.Advance(TimeSpan.FromMilliseconds(74_300)); // lands exactly on a tick

        Assert.Equal("01:14", status.Elapsed);
    }

    [Fact]
    public void Dispose_ends_the_operation_even_when_the_using_block_throws()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        Assert.Throws<InvalidOperationException>((Action)(() =>
        {
            using var op = status.Begin("Working…");
            time.Advance(TimeSpan.FromMilliseconds(300));
            throw new InvalidOperationException("boom");
        }));

        Assert.False(status.IsRunning);
        Assert.False(status.ShowIndicator);
    }

    [Fact]
    public void Ticks_that_drain_after_the_operation_ended_do_not_resurrect_the_indicator()
    {
        // The frozen-spinner bug, measured in the running app: the UI thread is blocked for ~2.4s
        // while the main window is built. The tick timer fires on a threadpool thread meanwhile and
        // only *posts* its UI work, so three ticks queued up, End ran first — and the queued ticks
        // then set ShowIndicator back to true. The timer was already disposed, so nothing ever
        // cleared it again. A poster that queues instead of running stands in for that blocked
        // dispatcher.
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var queued = new List<Action>();
        var status = new OperationStatus(time, queued.Add);

        var op = status.Begin("Checking for updates…");
        time.Advance(TimeSpan.FromSeconds(3)); // ticks fire, their UI work piles up unprocessed
        op.Dispose();                          // the operation finishes before the queue drains
        Assert.False(status.ShowIndicator);

        foreach (var action in queued.ToArray()) action(); // dispatcher catches up

        Assert.False(status.ShowIndicator);
        Assert.False(status.IsRunning);
    }

    [Fact]
    public void Report_overwrites_the_label()
    {
        var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
        var status = Create(time);

        using var op = status.Begin("Merging…");
        status.Report("Verifying…");

        Assert.Equal("Verifying…", status.Label);
    }
}
