using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LiveChartsCore;
using LiveChartsCore.SkiaSharpView;
using LiveChartsCore.SkiaSharpView.Painting;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SkiaSharp;

namespace SharepointToolbox.ViewModels.Tabs;

public partial class StorageViewModel : FeatureViewModelBase
{
    private readonly IStorageService _storageService;
    private readonly ISessionManager _sessionManager;
    private readonly StorageCsvExportService _csvExportService;
    private readonly StorageHtmlExportService _htmlExportService;
    private readonly IBrandingService? _brandingService;
    private readonly IOwnershipElevationService? _ownershipService;
    private readonly SettingsService? _settingsService;
    private readonly ThemeManager? _themeManager;
    private readonly ILogger<FeatureViewModelBase> _logger;
    private TenantProfile? _currentProfile;

    [ObservableProperty]
    private bool _perLibrary = true;

    [ObservableProperty]
    private bool _includeSubsites;

    [ObservableProperty]
    private int _folderDepth;

    [ObservableProperty]
    private bool _isDonutChart = true;

    // ── Scan-time flags (control what is captured during the CSOM scan) ─────
    [ObservableProperty] private bool _scanHiddenLibraries = true;
    [ObservableProperty] private bool _scanPreservationHold = true;
    [ObservableProperty] private bool _scanListAttachments = true;
    [ObservableProperty] private bool _scanRecycleBin = true;

    // ── Report filter flags (gate which kinds appear in DataGrid + exports) ─
    [ObservableProperty] private bool _showLibraries = true;
    [ObservableProperty] private bool _showHiddenLibraries = true;
    [ObservableProperty] private bool _showPreservationHold = true;
    [ObservableProperty] private bool _showListAttachments = true;
    [ObservableProperty] private bool _showRecycleBin = true;
    [ObservableProperty] private bool _showSubsites = true;

    /// <summary>
    /// When true, recycle bin stage 1 + stage 2 collapse into a single
    /// "[Recycle Bin] Total" row whose size is the sum of both stages.
    /// When false, both stages render as separate rows.
    /// </summary>
    [ObservableProperty] private bool _combineRecycleBinStages = true;

    // SPO-reported site total (Site.Usage.Storage). Independent reference
    // value the user can compare against the scanned total.
    [ObservableProperty] private long _spoReportedTotalSize;

    partial void OnShowLibrariesChanged(bool value) => RebuildFilteredResults();
    partial void OnShowHiddenLibrariesChanged(bool value) => RebuildFilteredResults();
    partial void OnShowPreservationHoldChanged(bool value) => RebuildFilteredResults();
    partial void OnShowListAttachmentsChanged(bool value) => RebuildFilteredResults();
    partial void OnShowRecycleBinChanged(bool value) => RebuildFilteredResults();
    partial void OnShowSubsitesChanged(bool value) => RebuildFilteredResults();
    partial void OnCombineRecycleBinStagesChanged(bool value) => RebuildFilteredResults();

    /// <summary>0 = Single file, 1 = Split by site.</summary>
    [ObservableProperty] private int _splitModeIndex;
    /// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
    [ObservableProperty] private int _htmlLayoutIndex;

    private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
    private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;

    private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
    public ObservableCollection<FileTypeMetric> FileTypeMetrics
    {
        get => _fileTypeMetrics;
        private set
        {
            _fileTypeMetrics = value;
            OnPropertyChanged();
            UpdateChartSeries();
        }
    }

    public bool HasChartData => FileTypeMetrics.Count > 0;

    private IEnumerable<ISeries> _pieChartSeries = Enumerable.Empty<ISeries>();
    public IEnumerable<ISeries> PieChartSeries
    {
        get => _pieChartSeries;
        private set { _pieChartSeries = value; OnPropertyChanged(); }
    }

    private IEnumerable<ISeries> _barChartSeries = Enumerable.Empty<ISeries>();
    public IEnumerable<ISeries> BarChartSeries
    {
        get => _barChartSeries;
        private set { _barChartSeries = value; OnPropertyChanged(); }
    }

    private Axis[] _barXAxes = Array.Empty<Axis>();
    public Axis[] BarXAxes
    {
        get => _barXAxes;
        private set { _barXAxes = value; OnPropertyChanged(); }
    }

    private Axis[] _barYAxes = Array.Empty<Axis>();
    public Axis[] BarYAxes
    {
        get => _barYAxes;
        private set { _barYAxes = value; OnPropertyChanged(); }
    }

    // Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference
    // into their geometry on first Initialize() and never re-read the chart's paint
    // properties. Replacing instances on theme change has no effect — we mutate
    // .Color in place so the new theme color renders on the next frame.
    public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor));
    public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor));
    public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor));
    public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor));

    public bool IsMaxDepth
    {
        get => FolderDepth >= 999;
        set
        {
            if (value) FolderDepth = 999;
            else if (FolderDepth >= 999) FolderDepth = 0;
            OnPropertyChanged();
        }
    }

    // Raw scan output — never filtered. RebuildFilteredResults projects this
    // into Results based on the Show* flags.
    private List<StorageNode> _allNodes = new();

    private ObservableCollection<StorageNode> _results = new();
    public ObservableCollection<StorageNode> Results
    {
        get => _results;
        private set
        {
            _results = value;
            OnPropertyChanged();
            NotifySummaryProperties();
            ExportCsvCommand.NotifyCanExecuteChanged();
            ExportHtmlCommand.NotifyCanExecuteChanged();
        }
    }

    // ── Summary properties (computed from root-level library nodes) ─────────
    //
    // Recycle-bin contents are rolled into each library's TotalSizeBytes by the
    // StorageService (matches storman.aspx). Including the synthetic root-level
    // RecycleBin nodes here would double-count those bytes — filter them out.
    // SummaryRecycleBinSize below still reads from _allNodes so the bin metric
    // remains visible to the user.

    /// <summary>Sum of TotalSizeBytes across root-level non-bin nodes.</summary>
    public long SummaryTotalSize => Results
        .Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
        .Sum(n => n.TotalSizeBytes);

    /// <summary>Sum of VersionSizeBytes across root-level non-bin nodes.</summary>
    public long SummaryVersionSize => Results
        .Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
        .Sum(n => n.VersionSizeBytes);

    /// <summary>Sum of TotalFileCount across root-level non-bin nodes.</summary>
    public long SummaryFileCount => Results
        .Where(n => n.IndentLevel == 0 && n.Kind != StorageNodeKind.RecycleBin)
        .Sum(n => n.TotalFileCount);

    /// <summary>
    /// Aggregate recycle-bin size (stage 1 + stage 2 across all sites). Reads
    /// from the raw scan so it stays visible even when the user hides the
    /// recycle-bin row in the report filter.
    /// </summary>
    public long SummaryRecycleBinSize => _allNodes
        .Where(n => n.Kind == StorageNodeKind.RecycleBin)
        .Sum(n => n.TotalSizeBytes);

    public bool HasResults => Results.Count > 0;

    private void NotifySummaryProperties()
    {
        OnPropertyChanged(nameof(SummaryTotalSize));
        OnPropertyChanged(nameof(SummaryVersionSize));
        OnPropertyChanged(nameof(SummaryRecycleBinSize));
        OnPropertyChanged(nameof(SummaryFileCount));
        OnPropertyChanged(nameof(HasResults));
    }

    public IAsyncRelayCommand ExportCsvCommand { get; }
    public IAsyncRelayCommand ExportHtmlCommand { get; }

    public TenantProfile? CurrentProfile => _currentProfile;

    public StorageViewModel(
        IStorageService storageService,
        ISessionManager sessionManager,
        StorageCsvExportService csvExportService,
        StorageHtmlExportService htmlExportService,
        IBrandingService brandingService,
        ILogger<FeatureViewModelBase> logger,
        IOwnershipElevationService? ownershipService = null,
        SettingsService? settingsService = null,
        ThemeManager? themeManager = null)
        : base(logger)
    {
        _storageService    = storageService;
        _sessionManager    = sessionManager;
        _csvExportService  = csvExportService;
        _htmlExportService = htmlExportService;
        _brandingService   = brandingService;
        _ownershipService  = ownershipService;
        _settingsService   = settingsService;
        _themeManager      = themeManager;
        _logger            = logger;

        ExportCsvCommand  = new AsyncRelayCommand(ExportCsvAsync,  CanExport);
        ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);

        ApplyChartThemeColors();
        if (_themeManager is not null)
            _themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
    }

    /// <summary>Test constructor — omits export services.</summary>
    internal StorageViewModel(
        IStorageService storageService,
        ISessionManager sessionManager,
        ILogger<FeatureViewModelBase> logger,
        IBrandingService? brandingService = null)
        : base(logger)
    {
        _storageService    = storageService;
        _sessionManager    = sessionManager;
        _csvExportService  = null!;
        _htmlExportService = null!;
        _brandingService   = brandingService;
        _logger            = logger;

        ExportCsvCommand  = new AsyncRelayCommand(ExportCsvAsync,  CanExport);
        ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
    }

    protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
    {
        if (_currentProfile == null)
        {
            StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
            return;
        }

        var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
        if (urls.Count == 0)
        {
            StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
            return;
        }

        var nonEmpty = urls;

        var options = new StorageScanOptions(
            PerLibrary: PerLibrary,
            IncludeSubsites: IncludeSubsites,
            FolderDepth: FolderDepth,
            IncludeHiddenLibraries: ScanHiddenLibraries,
            IncludePreservationHold: ScanPreservationHold,
            IncludeListAttachments: ScanListAttachments,
            IncludeRecycleBin: ScanRecycleBin);

        var allNodes = new List<StorageNode>();
        var allTypeMetrics = new List<FileTypeMetric>();
        long spoReportedTotal = 0;

        var autoOwnership = await IsAutoTakeOwnershipEnabled();

        int i = 0;
        foreach (var url in nonEmpty)
        {
            ct.ThrowIfCancellationRequested();
            i++;
            progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));

            var siteProfile = new TenantProfile
            {
                TenantUrl = url.TrimEnd('/'),
                ClientId = _currentProfile.ClientId,
                Name = _currentProfile.Name
            };

            var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);

            IReadOnlyList<StorageNode> nodes;
            try
            {
                nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
            }
            catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
            {
                _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
                var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
                var adminProfile = new TenantProfile
                {
                    TenantUrl = adminUrl,
                    ClientId = _currentProfile.ClientId,
                    Name = _currentProfile.Name
                };
                var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
                await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);

                ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
                nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
            }

            // Backfill any libraries where StorageMetrics returned zeros
            await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);

            allNodes.AddRange(nodes);

            progress.Report(OperationProgress.Indeterminate($"Scanning file types: {url}..."));
            var typeMetrics = await _storageService.CollectFileTypeMetricsAsync(ctx, progress, ct);
            allTypeMetrics.AddRange(typeMetrics);

            spoReportedTotal += await _storageService.GetSiteUsageStorageBytesAsync(ctx, progress, ct);
        }

        // Flatten tree for DataGrid display
        var flat = new List<StorageNode>();
        foreach (var node in allNodes)
            FlattenNode(node, 0, flat);

        // Merge file-type metrics across sites (same extension -> sum)
        var mergedMetrics = allTypeMetrics
            .GroupBy(m => m.Extension, StringComparer.OrdinalIgnoreCase)
            .Select(g => new FileTypeMetric(g.Key, g.Sum(m => m.TotalSizeBytes), g.Sum(m => m.FileCount)))
            .OrderByDescending(m => m.TotalSizeBytes)
            .ToList();

        if (Application.Current?.Dispatcher is { } dispatcher)
        {
            await dispatcher.InvokeAsync(() =>
            {
                _allNodes = flat;
                SpoReportedTotalSize = spoReportedTotal;
                RebuildFilteredResults();
                FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
            });
        }
        else
        {
            _allNodes = flat;
            SpoReportedTotalSize = spoReportedTotal;
            RebuildFilteredResults();
            FileTypeMetrics = new ObservableCollection<FileTypeMetric>(mergedMetrics);
        }
    }

    /// <summary>
    /// Project <see cref="_allNodes"/> into <see cref="Results"/> using the
    /// Show* flags. Nodes whose root ancestor is excluded by the flags are
    /// dropped along with their entire subtree, preserving DFS ordering.
    /// </summary>
    private void RebuildFilteredResults()
    {
        if (_allNodes.Count == 0)
        {
            Results = new ObservableCollection<StorageNode>();
            return;
        }

        var filtered = new List<StorageNode>(_allNodes.Count);
        bool includeCurrentSubtree = true;

        foreach (var node in _allNodes)
        {
            if (node.IndentLevel == 0)
                includeCurrentSubtree = IsKindShown(node.Kind);
            if (includeCurrentSubtree)
                filtered.Add(node);
        }

        if (CombineRecycleBinStages)
            filtered = CombineRecycleBins(filtered);

        Results = new ObservableCollection<StorageNode>(filtered);
    }

    /// <summary>
    /// Replaces all root recycle-bin nodes (stage 1 + stage 2) with a single
    /// aggregate row inserted at the position of the first recycle-bin node
    /// encountered. Preserves SiteTitle grouping when scans cover multiple
    /// sites by aggregating per SiteTitle.
    /// </summary>
    private static List<StorageNode> CombineRecycleBins(List<StorageNode> input)
    {
        var byPath = new Dictionary<string, StorageNode>(StringComparer.OrdinalIgnoreCase);
        var result = new List<StorageNode>(input.Count);

        foreach (var node in input)
        {
            if (node.IndentLevel == 0 && node.Kind == StorageNodeKind.RecycleBin)
            {
                string key = node.SiteTitle ?? string.Empty;
                if (!byPath.TryGetValue(key, out var agg))
                {
                    agg = new StorageNode
                    {
                        Name        = "[Recycle Bin] Total",
                        SiteTitle   = node.SiteTitle ?? string.Empty,
                        Library     = "RecycleBin",
                        Kind        = StorageNodeKind.RecycleBin,
                        IndentLevel = 0,
                        Children    = new List<StorageNode>()
                    };
                    byPath[key] = agg;
                    result.Add(agg);
                }
                agg.TotalSizeBytes      += node.TotalSizeBytes;
                agg.FileStreamSizeBytes += node.FileStreamSizeBytes;
                agg.TotalFileCount      += node.TotalFileCount;
                if (node.LastModified.HasValue &&
                    (!agg.LastModified.HasValue || node.LastModified > agg.LastModified))
                    agg.LastModified = node.LastModified;
            }
            else
            {
                result.Add(node);
            }
        }

        return result;
    }

    private bool IsKindShown(StorageNodeKind kind) => kind switch
    {
        StorageNodeKind.Library          => ShowLibraries,
        StorageNodeKind.HiddenLibrary    => ShowHiddenLibraries,
        StorageNodeKind.PreservationHold => ShowPreservationHold,
        StorageNodeKind.ListAttachments  => ShowListAttachments,
        StorageNodeKind.RecycleBin       => ShowRecycleBin,
        StorageNodeKind.Subsite          => ShowSubsites,
        _ => true
    };

    protected override void OnTenantSwitched(TenantProfile profile)
    {
        _currentProfile = profile;
        _allNodes = new List<StorageNode>();
        SpoReportedTotalSize = 0;
        Results = new ObservableCollection<StorageNode>();
        FileTypeMetrics = new ObservableCollection<FileTypeMetric>();
        OnPropertyChanged(nameof(CurrentProfile));
        ExportCsvCommand.NotifyCanExecuteChanged();
        ExportHtmlCommand.NotifyCanExecuteChanged();
    }

    private async Task<bool> IsAutoTakeOwnershipEnabled()
    {
        if (_settingsService == null) return false;
        var settings = await _settingsService.GetSettingsAsync();
        return settings.AutoTakeOwnership;
    }

    internal static string DeriveAdminUrl(string tenantUrl)
    {
        var uri = new Uri(tenantUrl.TrimEnd('/'));
        var host = uri.Host;
        if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
            return tenantUrl;
        var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
            StringComparison.OrdinalIgnoreCase);
        return $"{uri.Scheme}://{adminHost}";
    }

    internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;

    internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
        => RunOperationAsync(ct, progress);

    private bool CanExport() => Results.Count > 0;

    private async Task ExportCsvAsync()
    {
        if (Results.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export storage metrics to CSV",
            Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
            DefaultExt = "csv",
            FileName = "storage_metrics"
        };
        if (dialog.ShowDialog() != true) return;
        try
        {
            await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
            OpenFile(dialog.FileName);
        }
        catch (Exception ex)
        {
            StatusMessage = $"Export failed: {ex.Message}";
            _logger.LogError(ex, "CSV export failed.");
        }
    }

    private async Task ExportHtmlAsync()
    {
        if (Results.Count == 0) return;
        var dialog = new SaveFileDialog
        {
            Title = "Export storage metrics to HTML",
            Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
            DefaultExt = "html",
            FileName = "storage_metrics"
        };
        if (dialog.ShowDialog() != true) return;
        try
        {
            ReportBranding? branding = null;
            if (_brandingService is not null)
            {
                var mspLogo = await _brandingService.GetMspLogoAsync();
                var clientLogo = _currentProfile?.ClientLogo;
                branding = new ReportBranding(mspLogo, clientLogo);
            }

            await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
            OpenFile(dialog.FileName);
        }
        catch (Exception ex)
        {
            StatusMessage = $"Export failed: {ex.Message}";
            _logger.LogError(ex, "HTML export failed.");
        }
    }

    partial void OnIsDonutChartChanged(bool value)
    {
        UpdateChartSeries();
    }

    private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
    private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
    private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);

    private void ApplyChartThemeColors()
    {
        LegendTextPaint.Color = ChartFgColor;
        LegendBackgroundPaint.Color = ChartSurfaceColor;
        TooltipTextPaint.Color = ChartFgColor;
        TooltipBackgroundPaint.Color = ChartSurfaceColor;
    }

    private void UpdateChartSeries()
    {
        var metrics = FileTypeMetrics.ToList();
        OnPropertyChanged(nameof(HasChartData));

        ApplyChartThemeColors();

        if (metrics.Count == 0)
        {
            PieChartSeries = Enumerable.Empty<ISeries>();
            BarChartSeries = Enumerable.Empty<ISeries>();
            BarXAxes = Array.Empty<Axis>();
            BarYAxes = Array.Empty<Axis>();
            return;
        }

        // Take top 10 by size, aggregate the rest as "Other"
        var top = metrics.Take(10).ToList();
        long otherSize = metrics.Skip(10).Sum(m => m.TotalSizeBytes);
        int otherCount = metrics.Skip(10).Sum(m => m.FileCount);

        var chartItems = new List<FileTypeMetric>(top);
        if (otherSize > 0)
            chartItems.Add(new FileTypeMetric("Other", otherSize, otherCount));

        // Pie/Donut: one PieSeries per slice (LiveCharts2 requires this for per-slice colors).
        // Tooltip only shows the hovered slice because each series has exactly one value.
        double innerRadius = IsDonutChart ? 50 : 0;
        var pieList = new List<ISeries>();
        foreach (var m in chartItems)
        {
            pieList.Add(new PieSeries<double>
            {
                Values = new[] { (double)m.TotalSizeBytes },
                Name = m.DisplayLabel,
                InnerRadius = innerRadius,
                HoverPushout = 8,
                MaxRadialColumnWidth = 60,
                DataLabelsFormatter = _ => m.DisplayLabel,
                DataLabelsPaint = new SolidColorPaint(ChartFgColor),
                ToolTipLabelFormatter = _ =>
                    $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
                IsVisibleAtLegend = true,
            });
        }
        PieChartSeries = pieList;

        // Bar chart series
        BarChartSeries = new ISeries[]
        {
            new ColumnSeries<long>
            {
                Values = chartItems.Select(m => m.TotalSizeBytes).ToArray(),
                Name = "Size",
                DataLabelsFormatter = point =>
                {
                    int idx = (int)point.Index;
                    return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
                },
                DataLabelsPaint = new SolidColorPaint(ChartFgColor)
            }
        };

        BarXAxes = new Axis[]
        {
            new Axis
            {
                Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
                LabelsRotation = -45,
                LabelsPaint = new SolidColorPaint(ChartFgColor),
                TicksPaint  = new SolidColorPaint(ChartFgColor),
                SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
            }
        };

        BarYAxes = new Axis[]
        {
            new Axis
            {
                Labeler = value => FormatBytes((long)value),
                LabelsPaint = new SolidColorPaint(ChartFgColor),
                TicksPaint  = new SolidColorPaint(ChartFgColor),
                SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
            }
        };
    }

    private static string FormatBytes(long bytes)
    {
        if (bytes < 1024) return $"{bytes} B";
        if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
        if (bytes < 1024 * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
        return $"{bytes / (1024.0 * 1024 * 1024):F2} GB";
    }

    private static void FlattenNode(StorageNode node, int level, List<StorageNode> result)
    {
        node.IndentLevel = level;
        result.Add(node);
        foreach (var child in node.Children)
            FlattenNode(child, level + 1, result);
    }

    private static void OpenFile(string filePath)
    {
        try { Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true }); }
        catch { /* ignore -- file may open but this is best-effort */ }
    }
}
