using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace ClaudeDo.Ui.ViewModels.Modals;

public sealed partial class UsageMonitorModalViewModel : ViewModelBase
{
    private readonly IWorkerClient _worker;
    private bool _applyingRange;
    private bool _isClosed;

    public UsageMonitorModalViewModel(IWorkerClient worker) => _worker = worker;

    public Action? CloseAction { get; set; }
    public event Action<string>? ErrorReported;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsStale))]
    [NotifyPropertyChangedFor(nameof(LastError))]
    [NotifyPropertyChangedFor(nameof(IsGateBlocked))]
    [NotifyPropertyChangedFor(nameof(GateReason))]
    [NotifyPropertyChangedFor(nameof(StaleStampText))]
    [NotifyPropertyChangedFor(nameof(StaleBandText))]
    [NotifyPropertyChangedFor(nameof(GateBandText))]
    [NotifyPropertyChangedFor(nameof(IsThrottled))]
    [NotifyPropertyChangedFor(nameof(ThrottleBandText))]
    private UsageSnapshotDto? _snapshot;

    [ObservableProperty] private DateTime? _startDate;
    [ObservableProperty] private DateTime? _endDate;
    [ObservableProperty] private int _selectedPresetDays = 7;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ModelsEmpty))]
    private bool _isBusy;

    [ObservableProperty] private bool _isRefreshing;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ModelsEmpty))]
    [NotifyPropertyChangedFor(nameof(TotalsText))]
    private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TasksEmpty))]
    private IReadOnlyList<TaskUsageDisplayRow> _taskRows = Array.Empty<TaskUsageDisplayRow>();

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TokenTrackerMissing))]
    [NotifyPropertyChangedFor(nameof(ShowInstallButton))]
    [NotifyPropertyChangedFor(nameof(NodeHintText))]
    [NotifyPropertyChangedFor(nameof(TokenTrackerErrorText))]
    [NotifyPropertyChangedFor(nameof(HasTokenTrackerError))]
    [NotifyPropertyChangedFor(nameof(AnalyticsStampText))]
    private TokenTrackerStatusDto? _tokenTracker;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalsText))]
    private double _totalCostUsd;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ShowInstallButton))]
    private bool _isInstalling;

    [ObservableProperty] private bool _isOpeningDashboard;

    public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
    public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;

    public bool TokenTrackerMissing => TokenTracker is { Installed: false };
    public bool ShowInstallButton => TokenTracker is { Installed: false, NodeOk: true } && !IsInstalling;

    public string NodeHintText => TokenTracker switch
    {
        { NodeOk: true } => "",
        { NodeVersion: { Length: > 0 } v } => Loc.T("modals.usageMonitor.ttNodeHintFormat", v),
        _ => Loc.T("modals.usageMonitor.ttNodeMissing"),
    };

    public bool HasTokenTrackerError => TokenTracker?.LastError is { Length: > 0 };
    public string TokenTrackerErrorText => TokenTracker?.LastError is { Length: > 0 } e
        ? Loc.T("modals.usageMonitor.ttErrorFormat", e)
        : "";

    public string AnalyticsStampText => TokenTracker?.LastFetchedUtc is { } t
        ? Loc.T("modals.usageMonitor.analyticsStampFormat", t.ToLocalTime().ToString("HH:mm"))
        : Loc.T("modals.usageMonitor.analyticsNever");

    public string TotalsText => Loc.T(
        "modals.usageMonitor.analyticsTotalsFormat",
        ModelRows.Sum(r => r.GrandTotal).ToString("N0"),
        TotalCostUsd.ToString("0.00"));

    [ObservableProperty]
    private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();

    public bool IsStale => Snapshot?.IsStale == true;
    public string? LastError => Snapshot?.LastError;
    public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
    public string? GateReason => Snapshot?.GateReason;
    public string StaleStampText => Snapshot?.FetchedAtUtc is { } t ? t.ToLocalTime().ToString("HH:mm") : "?";
    public string StaleBandText => Loc.T("modals.usageMonitor.staleFormat", StaleStampText);
    public string GateBandText => GateReason is null ? "" : Loc.T("modals.usageMonitor.gateBlockedFormat", GateReason);

    public bool IsThrottled => Snapshot is { } s && !IsGateBlocked && s.EffectiveSlots < s.ConfiguredSlots;
    public string ThrottleBandText => Snapshot is not { } s || !IsThrottled
        ? ""
        : Loc.T("modals.usageMonitor.throttleFormat", s.EffectiveSlots, s.ConfiguredSlots, BucketLabel(s.ThrottleBucket));

    private static string BucketLabel(string? bucket) => bucket switch
    {
        "five_hour" => Loc.T("usage.pill.fiveHourLabel"),
        "seven_day" => Loc.T("usage.pill.sevenDayLabel"),
        _ => "",
    };

    [RelayCommand]
    private void Close()
    {
        _isClosed = true;
        _worker.UsageUpdatedEvent -= OnUsageUpdated;
        CloseAction?.Invoke();
    }

    /// <summary>
    /// Starts the load without blocking the caller, so the host can show the window right away and
    /// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
    /// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
    /// window made the usage pill look like it swallowed the click.
    /// </summary>
    public void BeginLoad() => _ = LoadAsync();

    public async Task LoadAsync()
    {
        IsBusy = true;
        try
        {
            var snapshot = await _worker.GetUsageSnapshotAsync();
            // The modal can be closed while the first-load scan is still running (see remarks
            // above) — subscribing after that would leak this VM onto the long-lived WorkerClient.
            if (_isClosed) return;
            Snapshot = snapshot;
            _worker.UsageUpdatedEvent -= OnUsageUpdated;
            _worker.UsageUpdatedEvent += OnUsageUpdated;
            ApplyPresetRange(SelectedPresetDays);
            await LoadUsageDataAsync();
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
        }
        finally { IsBusy = false; }
    }

    private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;

    /// <summary>
    /// Manual refresh: the worker polls usage on a slow cadence (15 min idle / 5 min while a
    /// task runs) to stay clear of the endpoint's 429s, so this is the way to get a number now.
    /// </summary>
    [RelayCommand]
    private async Task RefreshAsync()
    {
        if (IsRefreshing) return;
        IsRefreshing = true;
        try
        {
            var snapshot = await _worker.RefreshUsageAsync();
            if (snapshot is not null) Snapshot = snapshot;
            await LoadUsageDataAsync();
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.refreshFailed", ex.Message));
        }
        finally { IsRefreshing = false; }
    }

    private void ApplyPresetRange(int days)
    {
        var today = DateOnly.FromDateTime(DateTime.Today);
        _applyingRange = true;
        SelectedPresetDays = days;
        StartDate = today.AddDays(-(days - 1)).ToDateTime(TimeOnly.MinValue);
        EndDate = today.ToDateTime(TimeOnly.MinValue);
        _applyingRange = false;
    }

    [RelayCommand]
    private Task SetPreset7Days() => SetPreset(7);

    [RelayCommand]
    private Task SetPreset30Days() => SetPreset(30);

    [RelayCommand]
    private async Task RefreshAnalytics()
    {
        try
        {
            TokenTracker = await _worker.RefreshTokenTrackerAsync();
            await LoadUsageDataAsync();
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.analyticsRefreshFailed", ex.Message));
        }
    }

    // ErrorReported is the channel the shell hangs FlashFooterError on, so even the "install
    // running" notice goes through it rather than getting its own banner.
    [RelayCommand]
    private async Task InstallTokenTracker()
    {
        IsInstalling = true;
        ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installStarted"));
        try
        {
            TokenTracker = await _worker.InstallTokenTrackerAsync();
            if (TokenTracker is { Installed: true }) await RefreshAnalytics();
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installFailed", ex.Message));
        }
        finally { IsInstalling = false; }
    }

    /// <summary>Hands off to TokenTracker's own dashboard for everything this modal deliberately
    /// doesn't show. The worker starts the local server (first start takes a few seconds), we only
    /// open the browser.</summary>
    [RelayCommand]
    private async Task OpenDashboard()
    {
        IsOpeningDashboard = true;
        try
        {
            var dashboard = await _worker.OpenTokenTrackerDashboardAsync();
            if (dashboard is not { Ok: true, Url: { Length: > 0 } url })
            {
                ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
                    dashboard?.Error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
                return;
            }

            var (ok, error) = ShellOpen.Url(url);
            if (!ok)
                ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed",
                    error ?? Loc.T("vm.usageMonitor.dashboardUnavailable")));
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.dashboardFailed", ex.Message));
        }
        finally { IsOpeningDashboard = false; }
    }

    private Task SetPreset(int days)
    {
        ApplyPresetRange(days);
        return LoadUsageDataAsync();
    }

    partial void OnStartDateChanged(DateTime? value)
    {
        if (!_applyingRange) _ = LoadUsageDataAsync();
    }

    partial void OnEndDateChanged(DateTime? value)
    {
        if (!_applyingRange) _ = LoadUsageDataAsync();
    }

    private bool RangeValid => StartDate is not null && EndDate is not null && StartDate <= EndDate;

    private async Task LoadUsageDataAsync()
    {
        if (!RangeValid) return;
        IsBusy = true;
        try
        {
            var from = DateOnly.FromDateTime(StartDate!.Value);
            var to = DateOnly.FromDateTime(EndDate!.Value);
            var modelRows = await _worker.GetModelUsageAsync(from, to);
            var taskRows = await _worker.GetTaskUsageAsync(from, to);
            ModelRows = BuildModelDisplayRows(modelRows);
            TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0);
            TaskRows = taskRows
                .Select(r => new TaskUsageDisplayRow(
                    r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut,
                    r.CostUsd, r.Retries))
                .OrderByDescending(r => r.TotalTokens)
                .ToList();
            TokenTracker = await _worker.GetTokenTrackerStatusAsync();
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
        }
        finally { IsBusy = false; }
    }

    partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();

    /// <summary>
    /// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
    /// while the user works the markers doesn't swap the instances out from under the drag.
    /// </summary>
    private void SyncGaugeRows()
    {
        var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
        var existing = GaugeRows.ToDictionary(r => r.Key);
        var rows = new List<UsageGaugeRowViewModel>(limits.Count);

        foreach (var limit in limits)
        {
            var key = GaugeKey(limit);
            var bucket = GaugeBucket(limit);
            var (soft, hard, gate) = StagesFor(bucket);
            var label = BuildGaugeLabel(limit);

            if (existing.TryGetValue(key, out var row))
            {
                row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
                rows.Add(row);
            }
            else
            {
                rows.Add(new UsageGaugeRowViewModel(
                    key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
                    soft, hard, gate, SaveStagesAsync));
            }
        }

        if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
    }

    // Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
    private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
    {
        "session" => "five_hour",
        "weekly_all" => "seven_day",
        _ => null,
    };

    private static string GaugeKey(UsageLimitDto limit) =>
        limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;

    private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
    {
        ("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
        ("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
        _ => (null, null, null),
    };

    /// <summary>
    /// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
    /// this never clobbers a field the usage monitor doesn't own.
    /// </summary>
    private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
    {
        if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
            return;

        try
        {
            var settings = await _worker.GetAppSettingsAsync();
            if (settings is null)
            {
                ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
                return;
            }

            var updated = row.Bucket == "five_hour"
                ? settings with
                {
                    UsageThrottleFiveHourSoftPct = soft,
                    UsageThrottleFiveHourHardPct = hard,
                    UsageGateFiveHourPct = gate,
                }
                : settings with
                {
                    UsageThrottleSevenDaySoftPct = soft,
                    UsageThrottleSevenDayHardPct = hard,
                    UsageGateSevenDayPct = gate,
                };

            await _worker.UpdateAppSettingsAsync(updated);
        }
        catch (Exception ex)
        {
            ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
        }
    }

    private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
    {
        "session" => Loc.T("modals.usageMonitor.gaugeSession"),
        "weekly_all" => Loc.T("modals.usageMonitor.gaugeWeeklyAll"),
        "weekly_scoped" when !string.IsNullOrWhiteSpace(limit.ScopeModelDisplayName)
            => Loc.T("modals.usageMonitor.gaugeWeeklyScopedFormat", limit.ScopeModelDisplayName!),
        _ => limit.Kind,
    };

    private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
    {
        var built = new List<ModelUsageDisplayRow>();
        foreach (var group in rows.GroupBy(r => r.Model))
        {
            long cdIn = 0, cdOut = 0, cdCache = 0, otIn = 0, otOut = 0, otCache = 0;
            double cost = 0;
            foreach (var row in group)
            {
                var cache = row.CacheReadTokens + row.CacheCreationTokens;
                cost += row.CostUsd ?? 0;
                if (string.Equals(row.Scope, "ClaudeDo", StringComparison.OrdinalIgnoreCase))
                {
                    cdIn += row.InputTokens; cdOut += row.OutputTokens; cdCache += cache;
                }
                else
                {
                    otIn += row.InputTokens; otOut += row.OutputTokens; otCache += cache;
                }
            }
            built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache, cost));
        }

        var grandTotal = built.Sum(r => r.GrandTotal);
        return built
            .Select(r => r with { SharePercent = grandTotal > 0 ? r.GrandTotal * 100.0 / grandTotal : 0 })
            .OrderByDescending(r => r.GrandTotal)
            .ToList();
    }
}

/// <summary>
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
/// </summary>
public sealed partial class UsageGaugeRowViewModel : ObservableObject
{
    private readonly Func<UsageGaugeRowViewModel, Task>? _commit;

    public UsageGaugeRowViewModel(
        string key,
        string? bucket,
        string label,
        double percent,
        string severity,
        DateTimeOffset? resetsAt,
        int? softPct,
        int? hardPct,
        int? gatePct,
        Func<UsageGaugeRowViewModel, Task>? commit = null)
    {
        Key = key;
        Bucket = bucket;
        _label = label;
        _percent = percent;
        _severity = severity;
        _resetsAt = resetsAt;
        _softPct = softPct;
        _hardPct = hardPct;
        _gatePct = gatePct;
        _commit = commit;
    }

    /// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
    public string Key { get; }

    /// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
    public string? Bucket { get; }

    [ObservableProperty] private string _label;
    [ObservableProperty] private double _percent;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
    private string _severity;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(ResetText))]
    private DateTimeOffset? _resetsAt;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsAdjustable))]
    private int? _softPct;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsAdjustable))]
    private int? _hardPct;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsAdjustable))]
    private int? _gatePct;

    public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;

    public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);

    // Includes the absolute HH:mm alongside the countdown, unlike the pill tooltip's shorter
    // "resetIn" line (usage.pill.resetIn) — the modal has room for the detail, the tooltip doesn't.
    public string ResetText => ResetsAt is { } r
        ? Loc.T("modals.usageMonitor.resetIn", UsageTimeFormat.FormatRemaining(r), r.ToLocalTime().ToString("HH:mm"))
        : "";

    /// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
    public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
        int? softPct, int? hardPct, int? gatePct)
    {
        Label = label;
        Percent = percent;
        Severity = severity;
        ResetsAt = resetsAt;
        SoftPct = softPct;
        HardPct = hardPct;
        GatePct = gatePct;
    }

    /// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
    [RelayCommand]
    private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;

    // One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
    // can't invert the order — and only the edited stage moves, never its neighbours.
    [RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
    [RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
    [RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);

    private Task CommitStage(UsageThresholdDrag.Stage stage)
    {
        if (!IsAdjustable) return Task.CompletedTask;

        var edited = stage switch
        {
            UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
            UsageThresholdDrag.Stage.Hard => HardPct!.Value,
            _ => GatePct!.Value,
        };

        var (soft, hard, gate) = UsageThresholdDrag.Apply(
            SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
        SoftPct = soft;
        HardPct = hard;
        GatePct = gate;

        return Commit();
    }
}

/// <summary>
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
/// at 0 means "that stage is off" and therefore does not constrain anything.
/// </summary>
public static class UsageThresholdDrag
{
    public enum Stage { Soft, Hard, Gate }

    /// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
    public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
    {
        Stage? best = null;
        var bestDistance = double.MaxValue;

        foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
        {
            var distance = Math.Abs(percent - value);
            if (distance > tolerancePercent || distance >= bestDistance) continue;
            best = stage;
            bestDistance = distance;
        }

        return best;
    }

    public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
    {
        var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));

        return stage switch
        {
            Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
            Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
            Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
            _ => (soft, hard, gate),
        };
    }

    // A neighbour of 0 is switched off and must not pin the dragged marker to 0.
    private static int UpperBound(int nearest, int fallback) =>
        nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);

    // An already-inconsistent stored config (min above max) must not throw mid-drag.
    private static int ClampRange(int value, int min, int max) =>
        max < min ? max : Math.Clamp(value, min, max);
}

public sealed record ModelUsageDisplayRow(
    string Model,
    long ClaudeDoInputTokens,
    long ClaudeDoOutputTokens,
    long ClaudeDoCacheTokens,
    long OtherInputTokens,
    long OtherOutputTokens,
    long OtherCacheTokens,
    double CostUsd)
{
    public double SharePercent { get; init; }
    public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens;
    public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens;
    public long GrandTotal => ClaudeDoTotal + OtherTotal;
    public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—";
}

public sealed record TaskUsageDisplayRow(
    string TaskId,
    string TaskTitle,
    string ListName,
    string? Model,
    int Runs,
    long TokensIn,
    long TokensOut,
    double? CostUsd,
    int? Retries)
{
    public long TotalTokens => TokensIn + TokensOut;
    public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—";
    public string RetriesText => Retries?.ToString() ?? "—";
}
