using System.Threading;
using CommunityToolkit.Mvvm.ComponentModel;

namespace ClaudeDo.Ui.Services;

/// Feedback primitive for a single long-running operation behind a [RelayCommand]. Multiple
/// independent instances per ViewModel are expected (one per command) — there is no shared or
/// static state, which is exactly what a static DispatcherTimer would introduce and the cause of
/// the order-dependent flakiness in Ui.Tests.
///
/// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other
/// three write their observable properties inline on the calling thread, so an off-thread caller
/// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its
/// awaits resume on the UI thread); wrapping the call in Task.Run breaks it.
public sealed partial class OperationStatus : ObservableObject
{
    private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300);
    private static readonly TimeSpan StallThreshold = TimeSpan.FromSeconds(60);
    private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);

    private readonly TimeProvider _time;
    private readonly Action<Action> _postToUiThread;

    private ITimer? _tickTimer;
    private DateTimeOffset _startedAt;
    private DateTimeOffset _lastReportAt;
    private int _generation;

    [ObservableProperty] private bool _isRunning;
    [ObservableProperty] private bool _showIndicator;
    [ObservableProperty] private string? _label;
    [ObservableProperty] private string _elapsed = "00:00";
    [ObservableProperty] private bool _isStalled;

    public OperationStatus(TimeProvider? timeProvider = null) : this(timeProvider, null) { }

    /// Test-only seam. Production always marshals the threadpool timer callback onto the
    /// dispatcher; tests inject a synchronous poster so they can drive a fake clock without
    /// bootstrapping a real Avalonia dispatcher.
    internal OperationStatus(TimeProvider? timeProvider, Action<Action>? postToUiThread)
    {
        _time = timeProvider ?? TimeProvider.System;
        _postToUiThread = postToUiThread ?? (action => Avalonia.Threading.Dispatcher.UIThread.Post(action));
    }

    /// Starts the operation; Dispose (even via an exception unwinding a `using` block) ends it.
    public IDisposable Begin(string label)
    {
        _tickTimer?.Dispose();
        var generation = ++_generation;

        _startedAt = _time.GetUtcNow();
        _lastReportAt = _startedAt;
        Label = label;
        Elapsed = FormatElapsed(TimeSpan.Zero);
        ShowIndicator = false;
        IsStalled = false;
        IsRunning = true;

        _tickTimer = _time.CreateTimer(_ => OnTick(generation), null, GracePeriod, TickInterval);

        return new EndOnDispose(this, generation);
    }

    /// Overwrites the label mid-flight and resets the stall clock — call this whenever the
    /// operation's phase changes so a normally multi-minute step doesn't report itself as stuck.
    public void Report(string label)
    {
        Label = label;
        _lastReportAt = _time.GetUtcNow();
        IsStalled = false;
    }

    private void OnTick(int generation) => _postToUiThread(() =>
    {
        if (generation != _generation) return;

        var now = _time.GetUtcNow();
        Elapsed = FormatElapsed(now - _startedAt);
        if (!ShowIndicator && now - _startedAt >= GracePeriod) ShowIndicator = true;
        IsStalled = now - _lastReportAt >= StallThreshold;
    });

    private void End(int generation)
    {
        if (generation != _generation) return;
        // Retire the generation *before* touching the flags. The timer callback fires on a
        // threadpool thread and only posts its UI work, so a tick that fired while the dispatcher
        // was busy can drain *after* End — with the generation still current it would set
        // ShowIndicator back to true on an already-finished operation, and since the timer is gone
        // by then no later tick ever clears it. That is the frozen spinner.
        _generation++;
        _tickTimer?.Dispose();
        _tickTimer = null;
        IsRunning = false;
        ShowIndicator = false;
        IsStalled = false;
    }

    private static string FormatElapsed(TimeSpan elapsed) =>
        (elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed).ToString(@"mm\:ss");

    private sealed class EndOnDispose : IDisposable
    {
        private OperationStatus? _owner;
        private readonly int _generation;

        public EndOnDispose(OperationStatus owner, int generation)
        {
            _owner = owner;
            _generation = generation;
        }

        public void Dispose()
        {
            var owner = _owner;
            _owner = null;
            owner?.End(_generation);
        }
    }
}
