@page "/settings/storage-insights"
@inherits MainBase
@inject AliasVault.Client.Services.FaviconService FaviconService
@inject AliasVault.Client.Services.ItemService ItemService
@inject AliasVault.RazorComponents.Services.ConfirmModalService ConfirmModalService
@using AliasClientDb.Models
@using AliasVault.RazorComponents.Tables
@using Microsoft.EntityFrameworkCore
@using Microsoft.Extensions.Localization

<LayoutPageTitle>@Localizer["PageTitle"]</LayoutPageTitle>

<PageHeader
    BreadcrumbItems="@BreadcrumbItems"
    Title="@Localizer["PageTitle"]"
    Description="@Localizer["PageDescription"]">
</PageHeader>

<PageContent>
@if (IsLoading)
{
    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <p class="text-gray-600 dark:text-gray-400">@SharedLocalizer["Loading"]</p>
    </div>
}
else
{
    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["EstimatedTotalTitle"]</h3>
        <p class="text-4xl font-semibold text-gray-900 dark:text-white">@FormatSize(_estimatedTotalBytes)</p>
        <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">@Localizer["EstimatedTotalDescription"]</p>
    </div>

    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">@Localizer["CountsTitle"]</h3>
        <div class="grid gap-4 sm:grid-cols-3">
            <div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
                <p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemCountLabel"]</p>
                <p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_totalItems</p>
            </div>
            <div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
                <p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemsWithAttachmentsLabel"]</p>
                <p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_itemsWithAttachments</p>
            </div>
            <div class="p-4 border border-gray-200 rounded-lg dark:border-gray-700">
                <p class="text-sm text-gray-500 dark:text-gray-400">@Localizer["ItemsWithLogosLabel"]</p>
                <p class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">@_itemsWithLogos</p>
            </div>
        </div>
    </div>

    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["BreakdownTitle"]</h3>
        <p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["BreakdownDescription"]</p>

        @if (_estimatedTotalBytes == 0)
        {
            <p class="text-sm text-gray-500 dark:text-gray-400">-</p>
        }
        else
        {
            <div class="flex w-full h-4 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
                <div class="bg-gray-400 dark:bg-gray-500" style="width: @(Percent(_baseOverheadBytes, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownBaseOverheadLabel"]"></div>
                <div class="bg-blue-500" style="width: @(Percent(_credentialBytes, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownCredentialsLabel"]"></div>
                <div class="bg-amber-500" style="width: @(Percent(_attachmentBytesWithOverhead, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownAttachmentsLabel"]"></div>
                <div class="bg-emerald-500" style="width: @(Percent(_logoBytesWithOverhead, _estimatedTotalBytes).ToString("F2", System.Globalization.CultureInfo.InvariantCulture))%;" title="@Localizer["BreakdownLogosLabel"]"></div>
            </div>
            <div class="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4 text-sm">
                <div class="flex items-center gap-2">
                    <span class="inline-block w-3 h-3 rounded-sm bg-gray-400 dark:bg-gray-500"></span>
                    <span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownBaseOverheadLabel"]:</span>
                    <span class="font-medium text-gray-900 dark:text-white">@FormatSize(_baseOverheadBytes) (@Percent(_baseOverheadBytes, _estimatedTotalBytes).ToString("F1")%)</span>
                </div>
                <div class="flex items-center gap-2">
                    <span class="inline-block w-3 h-3 rounded-sm bg-blue-500"></span>
                    <span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownCredentialsLabel"]:</span>
                    <span class="font-medium text-gray-900 dark:text-white">@FormatSize(_credentialBytes) (@Percent(_credentialBytes, _estimatedTotalBytes).ToString("F1")%)</span>
                </div>
                <div class="flex items-center gap-2">
                    <span class="inline-block w-3 h-3 rounded-sm bg-amber-500"></span>
                    <span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownAttachmentsLabel"]:</span>
                    <span class="font-medium text-gray-900 dark:text-white">@FormatSize(_attachmentBytesWithOverhead) (@Percent(_attachmentBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%)</span>
                </div>
                <div class="flex items-center gap-2">
                    <span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
                    <span class="text-gray-700 dark:text-gray-300">@Localizer["BreakdownLogosLabel"]:</span>
                    <span class="font-medium text-gray-900 dark:text-white">@FormatSize(_logoBytesWithOverhead) (@Percent(_logoBytesWithOverhead, _estimatedTotalBytes).ToString("F1")%)</span>
                </div>
            </div>
        }
    </div>

    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["TopAttachmentsTitle"]</h3>
        <p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["TopAttachmentsDescription"]</p>

        @if (_topAttachments.Count == 0)
        {
            <p class="text-sm text-gray-500 dark:text-gray-400">-</p>
        }
        else
        {
            <SortableTable Columns="@_attachmentColumns" SortColumn="@string.Empty" SortDirection="SortDirection.Descending" OnSortChanged="(_ => Task.CompletedTask)">
                @foreach (var attachment in _topAttachments)
                {
                    <SortableTableRow Class="cursor-pointer" OnClick="@(() => NavigateToItem(attachment.ItemId))">
                        <SortableTableColumn IsPrimary="true">@attachment.Filename</SortableTableColumn>
                        <SortableTableColumn>@FormatSize(attachment.SizeBytes)</SortableTableColumn>
                        <SortableTableColumn>@(string.IsNullOrWhiteSpace(attachment.ItemName) ? "-" : attachment.ItemName)</SortableTableColumn>
                        <SortableTableColumn>@attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd")</SortableTableColumn>
                    </SortableTableRow>
                }
            </SortableTable>
        }
    </div>

    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["TopLogosTitle"]</h3>
        <p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["TopLogosDescription"]</p>

        @if (_topLogos.Count == 0)
        {
            <p class="text-sm text-gray-500 dark:text-gray-400">-</p>
        }
        else
        {
            <SortableTable Columns="@_logoColumns" SortColumn="@string.Empty" SortDirection="SortDirection.Descending" OnSortChanged="(_ => Task.CompletedTask)">
                @foreach (var logo in _topLogos)
                {
                    <SortableTableRow Class="@(logo.FirstItemId.HasValue ? "cursor-pointer" : string.Empty)" OnClick="@(logo.FirstItemId.HasValue ? EventCallback.Factory.Create(this, () => NavigateToItem(logo.FirstItemId!.Value)) : default)">
                        <SortableTableColumn IsPrimary="true">@logo.Source</SortableTableColumn>
                        <SortableTableColumn>@FormatSize(logo.SizeBytes)</SortableTableColumn>
                        <SortableTableColumn>@logo.ItemCount</SortableTableColumn>
                    </SortableTableRow>
                }
            </SortableTable>
        }
    </div>

    <div class="p-4 mb-4 mx-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
        <h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">@Localizer["LogoManagementTitle"]</h3>
        <p class="mb-4 text-sm text-gray-500 dark:text-gray-400">@Localizer["LogoManagementDescription"]</p>

        @if (_isRedownloadingLogos)
        {
            <div class="mb-4">
                <div class="flex justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
                    <span>@SharedLocalizer["Loading"]</span>
                    <span>@_redownloadProgress / @_redownloadTotal</span>
                </div>
                <div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
                    <div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(RedownloadPercent().ToString("F1", System.Globalization.CultureInfo.InvariantCulture))%"></div>
                </div>
            </div>
        }

        <div class="flex flex-col sm:flex-row gap-3">
            <Button Color="default" OnClick="RedownloadAllLogos" IsDisabled="@(_isRedownloadingLogos || _isDeletingLogos)">@Localizer["RedownloadAllLogosButton"]</Button>
            <Button Color="danger" OnClick="DeleteAllLogos" IsDisabled="@(_isRedownloadingLogos || _isDeletingLogos)">@Localizer["DeleteAllLogosButton"]</Button>
        </div>
    </div>
}
</PageContent>

@code {
    // Approximate per-row SQLite overhead in bytes. SQLite uses 4 KiB pages by default;
    // small rows pack many per page, while large blobs spill into overflow pages. These
    // constants cover row headers and the indexed columns AliasClientDbContext defines.
    // They are intentionally rough — attachments and logos dominate real vault size.
    private const int PerItemOverheadBytes = 200;
    private const int PerFieldValueOverheadBytes = 80;
    private const int PerAttachmentOverheadBytes = 120;
    private const int PerLogoOverheadBytes = 150;
    private const int PerTotpOverheadBytes = 100;
    private const int PerPasskeyOverheadBytes = 200;

    // Empty AliasVault SQLite file is ~200 KB on disk: file header, page allocation
    // for each table/index, sqlite_master schema rows, and __EFMigrationsHistory.
    // Constant rather than measured because we don't have access to the file size
    // here and the value is stable across vaults of the same schema version.
    private const int BaseSqliteOverheadBytes = 200 * 1024;

    private readonly List<TableColumn> _attachmentColumns =
    [
        new TableColumn { Title = "Filename", Sortable = false },
        new TableColumn { Title = "Size", Sortable = false },
        new TableColumn { Title = "Item", Sortable = false },
        new TableColumn { Title = "Created", Sortable = false },
    ];

    private readonly List<TableColumn> _logoColumns =
    [
        new TableColumn { Title = "Source", Sortable = false },
        new TableColumn { Title = "Size", Sortable = false },
        new TableColumn { Title = "Used by", Sortable = false },
    ];

    private bool IsLoading { get; set; } = true;

    private int _totalItems;
    private int _itemsWithAttachments;
    private int _itemsWithLogos;
    private long _credentialBytes;
    private long _attachmentBytesWithOverhead;
    private long _logoBytesWithOverhead;
    private long _baseOverheadBytes;
    private long _estimatedTotalBytes;
    private List<AttachmentRow> _topAttachments = [];
    private List<LogoRow> _topLogos = [];

    private bool _isDeletingLogos;
    private bool _isRedownloadingLogos;
    private int _redownloadProgress;
    private int _redownloadTotal;

    private IStringLocalizer Localizer => LocalizerFactory.Create("Pages.Main.Settings.StorageInsights", "AliasVault.Client");

    /// <inheritdoc />
    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();
        BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = Localizer["BreadcrumbTitle"] });

        // Localize the table column headers (created at field init time before localizer was available).
        _attachmentColumns[0].Title = Localizer["ColumnFilename"];
        _attachmentColumns[1].Title = Localizer["ColumnSize"];
        _attachmentColumns[2].Title = Localizer["ColumnItem"];
        _attachmentColumns[3].Title = Localizer["ColumnCreated"];
        _logoColumns[0].Title = Localizer["ColumnWebsiteURL"];
        _logoColumns[1].Title = Localizer["ColumnSize"];
        _logoColumns[2].Title = Localizer["ColumnItemCount"];

        await LoadStatisticsAsync();
    }

    private static string FormatSize(long bytes)
    {
        const double Kib = 1024d;
        const double Mib = Kib * 1024d;

        if (bytes < Kib)
        {
            return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{bytes} B");
        }

        if (bytes < Mib)
        {
            return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Kib):F1} KB");
        }

        return string.Create(System.Globalization.CultureInfo.CurrentCulture, $"{(bytes / Mib):F1} MB");
    }

    private static double Percent(long part, long total)
    {
        return total == 0 ? 0d : Math.Round((double)part / total * 100d, 2);
    }

    private async Task LoadStatisticsAsync()
    {
        var ctx = await DbService.GetDbContextAsync();

        _totalItems = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null);
        _itemsWithAttachments = await ctx.Attachments
            .Where(a => !a.IsDeleted)
            .Select(a => a.ItemId)
            .Distinct()
            .CountAsync();
        _itemsWithLogos = await ctx.Items.CountAsync(i => !i.IsDeleted && i.DeletedAt == null && i.LogoId != null);

        var attachmentRows = await ctx.Attachments
            .Where(a => !a.IsDeleted)
            .Select(a => new AttachmentRow
            {
                Id = a.Id,
                Filename = a.Filename,
                SizeBytes = a.Blob.Length,
                ItemId = a.ItemId,
                ItemName = a.Item.Name,
                CreatedAt = a.CreatedAt,
            })
            .ToListAsync();

        var logoProjections = await ctx.Logos
            .Where(l => !l.IsDeleted && l.FileData != null)
            .Select(l => new
            {
                l.Id,
                l.Source,
                SizeBytes = l.FileData!.Length,
                ItemIds = l.Items
                    .Where(i => !i.IsDeleted && i.DeletedAt == null)
                    .Select(i => i.Id)
                    .ToList(),
            })
            .ToListAsync();

        var logoRows = logoProjections
            .Select(l => new LogoRow
            {
                Id = l.Id,
                Source = l.Source,
                SizeBytes = l.SizeBytes,
                ItemCount = l.ItemIds.Count,
                FirstItemId = l.ItemIds.Count > 0 ? l.ItemIds[0] : null,
            })
            .ToList();

        var fieldValueLengths = await ctx.FieldValues
            .Where(fv => !fv.IsDeleted)
            .Select(fv => fv.Value == null ? 0 : fv.Value.Length)
            .ToListAsync();
        var fieldValueBytes = fieldValueLengths.Sum(x => (long)x);
        var fieldValueCount = fieldValueLengths.Count;

        var passkeySizes = await ctx.Passkeys
            .Where(p => !p.IsDeleted)
            .Select(p => new
            {
                UserHandleLen = p.UserHandle.Length,
                PrfKeyLen = p.PrfKey == null ? 0 : p.PrfKey.Length,
                AdditionalDataLen = p.AdditionalData == null ? 0 : p.AdditionalData.Length,
                PublicKeyLen = p.PublicKey.Length,
                PrivateKeyLen = p.PrivateKey.Length,
            })
            .ToListAsync();
        var passkeyBytes = passkeySizes.Sum(p => (long)(p.UserHandleLen + p.PrfKeyLen + p.AdditionalDataLen + p.PublicKeyLen + p.PrivateKeyLen));
        var passkeyCount = passkeySizes.Count;

        var totpCount = await ctx.TotpCodes.CountAsync(t => !t.IsDeleted);

        var attachmentBytes = attachmentRows.Sum(a => (long)a.SizeBytes);
        var logoBytes = logoRows.Sum(l => (long)l.SizeBytes);

        _attachmentBytesWithOverhead = attachmentBytes + ((long)attachmentRows.Count * PerAttachmentOverheadBytes);
        _logoBytesWithOverhead = logoBytes + ((long)logoRows.Count * PerLogoOverheadBytes);

        _credentialBytes = fieldValueBytes
            + passkeyBytes
            + ((long)_totalItems * PerItemOverheadBytes)
            + ((long)fieldValueCount * PerFieldValueOverheadBytes)
            + ((long)passkeyCount * PerPasskeyOverheadBytes)
            + ((long)totpCount * PerTotpOverheadBytes);

        _baseOverheadBytes = BaseSqliteOverheadBytes;

        _estimatedTotalBytes = _credentialBytes
            + _attachmentBytesWithOverhead
            + _logoBytesWithOverhead
            + _baseOverheadBytes;

        _topAttachments = attachmentRows
            .OrderByDescending(a => a.SizeBytes)
            .Take(10)
            .ToList();

        _topLogos = logoRows
            .OrderByDescending(l => l.SizeBytes)
            .Take(10)
            .ToList();

        IsLoading = false;
        StateHasChanged();
    }

    private void NavigateToItem(Guid itemId)
    {
        NavigationManager.NavigateTo($"/items/{itemId}");
    }

    private double RedownloadPercent()
    {
        return _redownloadTotal == 0 ? 0d : Math.Round((double)_redownloadProgress / _redownloadTotal * 100d, 1);
    }

    /// <summary>
    /// Soft-deletes every logo in the vault and clears the LogoId on every item that referenced one.
    /// The vault sync that follows propagates these changes to other devices.
    /// </summary>
    private async Task DeleteAllLogos()
    {
        var confirmed = await ConfirmModalService.ShowConfirmation(
            Localizer["DeleteAllLogosConfirmTitle"],
            Localizer["DeleteAllLogosConfirmMessage"],
            SharedLocalizer["Confirm"],
            SharedLocalizer["Cancel"]);
        if (!confirmed)
        {
            return;
        }

        _isDeletingLogos = true;
        StateHasChanged();
        GlobalLoadingSpinner.Show(SharedLocalizer["Loading"]);

        try
        {
            var ctx = await DbService.GetDbContextAsync();
            var now = DateTime.UtcNow;

            // Soft-delete the row AND clear the binary blob to free up space.
            var logos = await ctx.Logos.Where(l => !l.IsDeleted).ToListAsync();
            foreach (var logo in logos)
            {
                logo.IsDeleted = true;
                logo.FileData = Array.Empty<byte>();
                logo.UpdatedAt = now;
            }

            // Clear LogoId on items so they no longer reference soft-deleted logos.
            var itemsWithLogos = await ctx.Items.Where(i => i.LogoId != null).ToListAsync();
            foreach (var item in itemsWithLogos)
            {
                item.LogoId = null;
                item.UpdatedAt = now;
            }

            await ctx.SaveChangesAsync();

            // VACUUM after a bulk blob delete — without it the SQLite pages stay allocated and
            // the user wouldn't actually see the size drop they came here for.
            await DbService.VacuumDatabaseAsync();
            await DbService.SaveDatabaseAsync();

            GlobalNotificationService.AddSuccessMessage(SharedLocalizer["Success"], true);
            await LoadStatisticsAsync();
        }
        catch (Exception)
        {
            GlobalNotificationService.AddErrorMessage(SharedLocalizer["ErrorGeneric"], true);
        }
        finally
        {
            GlobalLoadingSpinner.Hide();
            _isDeletingLogos = false;
            StateHasChanged();
        }
    }

    /// <summary>
    /// Re-downloads favicons for every item that has a login URL, overwriting any existing logo.
    /// Useful after the favicon extractor's sizing/quality settings change so the vault picks up
    /// the new output.
    /// </summary>
    private async Task RedownloadAllLogos()
    {
        var confirmed = await ConfirmModalService.ShowConfirmation(Localizer["RedownloadAllLogosConfirmTitle"], Localizer["RedownloadAllLogosConfirmMessage"], SharedLocalizer["Confirm"], SharedLocalizer["Cancel"]);
        if (!confirmed)
        {
            return;
        }

        _isRedownloadingLogos = true;
        _redownloadProgress = 0;
        _redownloadTotal = 0;
        StateHasChanged();

        try
        {
            var items = await ItemService.LoadAllAsync();

            // Group items by normalized domain so each domain is fetched once and assigned to all items.
            var itemsByDomain = new Dictionary<string, List<AliasClientDb.Item>>();
            var representativeUrls = new Dictionary<string, string>();
            foreach (var item in items)
            {
                var url = ItemService.GetFieldValue(item, FieldKey.LoginUrl);
                if (string.IsNullOrEmpty(url) || url == ItemService.DefaultServiceUrl)
                {
                    continue;
                }

                if (!FaviconService.TryNormalizeDomain(url, out var domain))
                {
                    continue;
                }

                if (!itemsByDomain.TryGetValue(domain, out var bucket))
                {
                    bucket = new List<AliasClientDb.Item>();
                    itemsByDomain[domain] = bucket;
                    representativeUrls[domain] = url;
                }

                bucket.Add(item);
            }

            _redownloadTotal = itemsByDomain.Count;
            StateHasChanged();

            if (_redownloadTotal == 0)
            {
                GlobalNotificationService.AddSuccessMessage(SharedLocalizer["Success"], true);
                return;
            }

            var progress = new Progress<int>(count =>
            {
                _redownloadProgress = count;
                InvokeAsync(StateHasChanged);
            });

            var result = await FaviconService.ExtractBulkAsync(representativeUrls.Values, progress);

            var ctx = await DbService.GetDbContextAsync();
            var now = DateTime.UtcNow;

            foreach (var (domain, bucket) in itemsByDomain)
            {
                if (!result.Favicons.TryGetValue(domain, out var image))
                {
                    continue;
                }

                // Look up by Source (unique field).
                var existingLogo = await ctx.Logos.FirstOrDefaultAsync(l => l.Source == domain);
                Guid logoId;
                if (existingLogo != null)
                {
                    existingLogo.IsDeleted = false;
                    existingLogo.FileData = image;
                    existingLogo.FetchedAt = now;
                    existingLogo.UpdatedAt = now;
                    logoId = existingLogo.Id;
                }
                else
                {
                    var newLogo = new AliasClientDb.Logo
                    {
                        Id = Guid.NewGuid(),
                        Source = domain,
                        FileData = image,
                        FetchedAt = now,
                        CreatedAt = now,
                        UpdatedAt = now,
                    };
                    ctx.Logos.Add(newLogo);
                    logoId = newLogo.Id;
                }

                foreach (var item in bucket)
                {
                    if (item.LogoId != logoId)
                    {
                        item.LogoId = logoId;
                        item.UpdatedAt = now;
                    }
                }
            }

            await ctx.SaveChangesAsync();
            await DbService.SaveDatabaseAsync();

            if (result.Status == FaviconService.BulkExtractStatus.RateLimited)
            {
                GlobalNotificationService.AddErrorMessage(SharedLocalizer["ErrorGeneric"], true);
            }
            else
            {
                GlobalNotificationService.AddSuccessMessage(SharedLocalizer["Success"], true);
            }

            await LoadStatisticsAsync();
        }
        catch (Exception)
        {
            GlobalNotificationService.AddErrorMessage(SharedLocalizer["ErrorGeneric"], true);
        }
        finally
        {
            _isRedownloadingLogos = false;
            StateHasChanged();
        }
    }

    private sealed class AttachmentRow
    {
        public Guid Id { get; init; }

        public string Filename { get; init; } = string.Empty;

        public int SizeBytes { get; init; }

        public Guid ItemId { get; init; }

        public string? ItemName { get; init; }

        public DateTime CreatedAt { get; init; }
    }

    private sealed class LogoRow
    {
        public Guid Id { get; init; }

        public string Source { get; init; } = string.Empty;

        public int SizeBytes { get; init; }

        public int ItemCount { get; init; }

        public Guid? FirstItemId { get; init; }
    }
}
