@inject ILogger<ImportServiceCard> Logger
@inject ItemService ItemService
@inject DbService DbService
@inject FolderService FolderService
@inject NavigationManager NavigationManager
@inject GlobalNotificationService GlobalNotificationService
@inject FaviconService FaviconService
@inject IStringLocalizerFactory LocalizerFactory
@inject AliasVault.Client.Services.Crypto.AvexCryptoService AvexCrypto
@inject JsInteropService JsInteropService
@using Microsoft.Extensions.Localization
@using AliasVault.ImportExport
@using AliasVault.ImportExport.Exceptions
@using AliasVault.ImportExport.Importers
@using AliasVault.ImportExport.Models
@using AliasVault.ImportExport.Models.Exports
@using AliasClientDb
@using AliasClientDb.Models
@using AliasVault.Client.Auth.Components

<div @onclick="OpenImportModal" data-import-service="@ServiceName" class="flex flex-col p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors duration-200">
    <div class="flex items-center">
        <div class="w-12 h-12 mr-3 flex-shrink-0 rounded-md flex items-center justify-center">
            @if (!string.IsNullOrEmpty(LogoUrl))
            {
                <img src="@LogoUrl" alt="@ServiceName logo" class="w-full h-full object-contain" />
            }
            else
            {
                <span class="text-gray-500 dark:text-gray-400 text-xs">@Localizer["NoLogoText"]</span>
            }
        </div>
        <div>
            <h4 class="text-lg font-semibold dark:text-white">@ServiceName</h4>
            @if (!string.IsNullOrEmpty(Description))
            {
                <p class="text-sm text-gray-500 dark:text-gray-400">@Description</p>
            }
        </div>
    </div>
</div>

@if (IsModalOpen)
{
    <ClickOutsideHandler OnClose="CloseModal" ContentId="importServiceModal">
        <ModalWrapper OnEnter="HandleModalConfirm">
            <div id="importServiceModal" class="relative top-20 mx-auto p-5 shadow-lg rounded-md bg-white dark:bg-gray-800 border-2 border-gray-300 dark:border-gray-400 md:min-w-[32rem]">
                <div class="bg-white dark:bg-gray-800 rounded-lg p-4 w-full mx-auto">
                    <div class="flex justify-between items-center mb-4">
                        <div class="flex"><img src="@LogoUrl" alt="@ServiceName logo" class="w-8 h-8 float-left mr-4" /><h3 class="text-xl font-semibold dark:text-white">@string.Format(Localizer["ImportFromServiceTitle"], ServiceName)</h3></div>
                        <button @onclick="CloseModal" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
                            <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
                            </svg>
                        </button>
                    </div>

                    <div class="w-full bg-gray-200 rounded-full h-2.5 mb-4 dark:bg-gray-700">
                        <div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetProgressPercentage())%"></div>
                    </div>
                    @switch (CurrentStep)
                    {
                        case ImportStep.FileUpload:
                            <div class="max-w-lg mx-auto">
                                @if (!string.IsNullOrEmpty(ImportError))
                                {
                                    @RenderImportError()
                                }

                                @if (IsImporting)
                                {
                                    <LoadingIndicator />
                                }

                                <div class="@(IsImporting ? "hidden" : "")">
                                    @ChildContent
                                    <p class="text-gray-600 dark:text-gray-400 text-sm mb-4">
                                        <strong>@Localizer["supportedFormats"]:</strong>
                                        @string.Join(", ", AcceptedFileExtensions)
                                    </p>
                                    <div class="mb-4 bg-amber-50 border border-amber-400 dark:bg-amber-800/30 dark:border-amber-500/50 rounded-lg p-4">
                                        <p class="mb-4 text-gray-700 dark:text-gray-200">@string.Format(Localizer["UploadExportFileText"], ServiceName)</p>
                                        <InputFile OnChange="HandleFileUpload" accept="@FileAcceptTypes" class="text-gray-700 dark:text-gray-200 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900/40 dark:file:text-primary-300 dark:hover:file:bg-primary-800/60" />
                                    </div>
                                    <div class="flex justify-end mt-6 space-x-2">
                                        <Button OnClick="@CloseModal" Color="secondary">@Localizer["CancelButton"]</Button>
                                    </div>
                                </div>
                            </div>
                            break;

                        case ImportStep.PasswordInput:
                            <div class="max-w-lg mx-auto">
                                @if (!string.IsNullOrEmpty(ImportError))
                                {
                                    @RenderImportError()
                                }

                                @if (IsImporting)
                                {
                                    <div class="text-center">
                                        <LoadingIndicator />
                                        <p class="mt-4 text-gray-700 dark:text-gray-300">@Localizer["DecryptingFile"]</p>
                                    </div>
                                }
                                else
                                {
                                    <div class="mb-4">
                                        <p class="mb-4 text-gray-700 dark:text-gray-300">@Localizer["EncryptedFilePasswordPrompt"]</p>
                                        <label for="decryptionPassword" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
                                            @Localizer["DecryptionPasswordLabel"]
                                        </label>
                                        <PasswordInputField
                                            Id="decryptionPassword"
                                            @bind-Value="_decryptionPassword"
                                            Placeholder=""
                                            @onkeydown="HandlePasswordKeyDown"
                                            autofocus="true" />
                                    </div>

                                    <div class="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg">
                                        <p class="text-xs text-orange-800 dark:text-orange-200">
                                            @Localizer["DecryptionPasswordHint"]
                                        </p>
                                    </div>

                                    <div class="flex justify-end mt-6 space-x-2">
                                        <Button OnClick="@HandlePreviousStep" Color="secondary">@Localizer["BackButton"]</Button>
                                        <Button OnClick="@HandleDecryptFile" Color="primary" IsDisabled="@string.IsNullOrWhiteSpace(_decryptionPassword)">@Localizer["DecryptAndContinueButton"]</Button>
                                    </div>
                                }
                            </div>
                            break;

                        case ImportStep.Preview:
                            <div class="mb-4">
                                @if (DuplicateCredentialsCount > 0)
                                {
                                    <div class="p-4 mb-4 text-gray-700 bg-gray-100 rounded-lg dark:bg-gray-700/50 dark:text-gray-300" role="alert">
                                        <p>@string.Format(Localizer["DuplicateCredentialsWarning"], DuplicateCredentialsCount)</p>
                                    </div>
                                }

                                @if (_parseFailures.Count > 0)
                                {
                                    <div class="mb-4 p-4 rounded-lg bg-amber-50 border border-amber-400 dark:bg-amber-900/20 dark:border-amber-700" role="alert">
                                        <div class="flex items-start gap-2 mb-2">
                                            <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 shrink-0 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
                                                <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.008v.008H12v-.008Z" />
                                            </svg>
                                            <p class="font-medium text-amber-900 dark:text-amber-200">@string.Format(Localizer["ImportPartialFailureWarning"], _parseFailures.Count)</p>
                                        </div>
                                        <ul class="mb-3 ml-7 text-xs list-disc pl-5 space-y-0.5 text-gray-700 dark:text-gray-300">
                                            @foreach (var failure in _parseFailures.Take(FailedItemsPreviewLimit))
                                            {
                                                <li>
                                                    <span class="font-medium">@(string.IsNullOrWhiteSpace(failure.ItemTitle) ? "(no title)" : failure.ItemTitle)</span>
                                                    <span class="opacity-75"> — @failure.ExceptionType</span>
                                                </li>
                                            }
                                        </ul>
                                        @if (_parseFailures.Count > FailedItemsPreviewLimit)
                                        {
                                            <p class="mb-3 ml-7 text-xs text-gray-700 dark:text-gray-300">@string.Format(Localizer["MoreCredentials"], _parseFailures.Count - FailedItemsPreviewLimit)</p>
                                        }
                                        <button type="button" @onclick="ToggleFailureDetails" class="ml-7 inline-flex items-center gap-1 text-xs font-medium text-amber-800 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-200 hover:underline">
                                            <span class="@(_showFailureDetails ? "rotate-90" : string.Empty) transition-transform">▸</span>
                                            @(_showFailureDetails ? Localizer["ImportErrorHideDetailsButton"] : Localizer["ImportErrorShowDetailsButton"])
                                        </button>
                                        @if (_showFailureDetails)
                                        {
                                            <div class="mt-2 ml-7 flex items-start gap-2">
                                                <textarea readonly rows="4" class="flex-1 text-xs font-mono p-2 rounded border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 leading-snug">@BuildFailureDetailsText()</textarea>
                                                <button type="button" @onclick="CopyFailedItemsDetails" class="shrink-0 inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700" title="@Localizer["ImportErrorCopyDetailsButton"]">
                                                    <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
                                                        <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
                                                    </svg>
                                                    @Localizer["ImportErrorCopyDetailsButton"]
                                                </button>
                                            </div>
                                        }
                                    </div>
                                }

                                @if (ImportedCredentials.Count == 0)
                                {
                                    <div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
                                        <p>@Localizer["NoNewCredentials"]</p>
                                    </div>
                                }
                                else
                                {
                                    <p class="mb-4 text-gray-700 dark:text-gray-300">@Localizer["PreviewInstructions"]</p>
                                    <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
                                        <thead class="bg-gray-50 dark:bg-gray-700">
                                            <tr>
                                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">@Localizer["ServiceColumn"]</th>
                                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">@Localizer["UsernameColumn"]</th>
                                                <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">@Localizer["PasswordColumn"]</th>
                                            </tr>
                                        </thead>
                                        <tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
                                            @foreach (var credential in ImportedCredentials.Take(3))
                                            {
                                                <tr>
                                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.ServiceName</td>
                                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@credential.Username</td>
                                                    <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">@(new string('*', credential.Password?.Length ?? 0))</td>
                                                </tr>
                                            }
                                        </tbody>
                                    </table>
                                    @if (ImportedCredentials.Count > 3)
                                    {
                                        <p class="mt-2 text-sm text-gray-500 dark:text-gray-400">@string.Format(Localizer["MoreCredentials"], ImportedCredentials.Count - 3)</p>
                                    }
                                }
                            </div>
                            @if (ImportedCredentials.Count > 0)
                            {
                                <div class="mb-4 space-y-2">
                                    <label class="flex items-center">
                                        <input type="checkbox" @bind="ExtractFavicons" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
                                        <span class="ml-2 text-gray-700 dark:text-gray-300">@Localizer["ExtractFaviconsLabel"]</span>
                                    </label>
                                    @if (DetectedFolderCount > 0)
                                    {
                                        <label class="flex items-center">
                                            <input type="checkbox" @bind="ImportFolders" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
                                            <span class="ml-2 text-gray-700 dark:text-gray-300">@Localizer["ImportFoldersLabel"]</span>
                                            <span class="ml-2 text-sm text-gray-500 dark:text-gray-400">(@string.Format(Localizer["FoldersDetected"], DetectedFolderCount))</span>
                                        </label>
                                    }
                                    @if (DetectedAttachmentCount > 0)
                                    {
                                        <label class="flex items-center">
                                            <input type="checkbox" @bind="ImportAttachments" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
                                            <span class="ml-2 text-gray-700 dark:text-gray-300">@Localizer["ImportAttachmentsLabel"]</span>
                                            <span class="ml-2 text-sm text-gray-500 dark:text-gray-400">(@string.Format(Localizer["AttachmentsDetected"], DetectedAttachmentCount, FormatBytes(DetectedAttachmentsTotalSize)))</span>
                                        </label>
                                    }
                                </div>
                            }
                            <div class="flex justify-end mt-6 space-x-2">
                                <Button OnClick="@HandlePreviousStep" Color="secondary">@Localizer["BackButton"]</Button>
                                @if (ImportedCredentials.Count > 0)
                                {
                                    <Button OnClick="@HandleNextStep" Color="primary">@Localizer["NextButton"]</Button>
                                }
                            </div>
                            break;

                        case ImportStep.Confirm:
                            <div class="max-w-lg mx-auto">
                                @if (IsImporting)
                                {
                                    @if (IsExtractingFavicons)
                                    {
                                        <div class="text-center">
                                            <LoadingIndicator />
                                            <p class="mt-4 text-gray-700 dark:text-gray-300">@string.Format(Localizer["ExtractingFavicons"], FaviconExtractionProgress, TotalFaviconsToExtract)</p>
                                            <div class="mt-4">
                                                <Button OnClick="@CancelFaviconExtraction" Color="secondary">@Localizer["CancelButton"]</Button>
                                            </div>
                                        </div>
                                    }
                                    else if (IsSavingCredentials)
                                    {
                                        <div class="text-center">
                                            <LoadingIndicator />
                                            <p class="mt-4 text-gray-700 dark:text-gray-300">@string.Format(Localizer["SavingCredentials"], CredentialSaveProgress, TotalCredentialsToSave)</p>
                                        </div>
                                    }
                                    else if (IsSyncingVault)
                                    {
                                        <div class="text-center">
                                            <LoadingIndicator />
                                            <p class="mt-4 text-gray-700 dark:text-gray-300">@Localizer["SyncingVault"]</p>
                                        </div>
                                    }
                                    else
                                    {
                                        <LoadingIndicator />
                                    }
                                }
                                else {
                                    <div class="mb-4">
                                        <p class="mb-4 text-gray-700 dark:text-gray-300">@string.Format(Localizer["ConfirmImportText"], ImportedCredentials.Count)</p>
                                        @if (ExtractFavicons)
                                        {
                                            <div class="p-4 mb-4 text-amber-700 bg-amber-100 rounded-lg dark:bg-amber-800/30 dark:text-amber-300" role="alert">
                                                <p>@Localizer["FaviconExtractionNote"]</p>
                                            </div>
                                        }
                                    </div>
                                    <div class="flex justify-end mt-6 space-x-2">
                                        <Button OnClick="@HandlePreviousStep" Color="secondary">@Localizer["BackButton"]</Button>
                                        <Button OnClick="@HandleModalConfirm" Color="primary">@Localizer["ImportButton"]</Button>
                                    </div>
                                }
                            </div>
                            break;
                    }
                </div>
            </div>
        </ModalWrapper>
    </ClickOutsideHandler>
}

@code {
    private IStringLocalizer Localizer => LocalizerFactory.Create("Components.Main.Settings.ImportExport.ImportServiceCard", "AliasVault.Client");

    private enum ImportStep
    {
        FileUpload,
        PasswordInput,
        Preview,
        Confirm
    }

    /// <summary>
    /// The name of the service.
    /// </summary>
    [Parameter]
    public string ServiceName { get; set; } = string.Empty;

    /// <summary>
    /// The description of the service.
    /// </summary>
    [Parameter]
    public string Description { get; set; } = string.Empty;

    /// <summary>
    /// The URL of the logo of the service.
    /// </summary>
    [Parameter]
    public string LogoUrl { get; set; } = string.Empty;

    /// <summary>
    /// The event callback for when the import is confirmed.
    /// </summary>
    [Parameter]
    public EventCallback OnImportConfirmed { get; set; }

    /// <summary>
    /// The callback for processing files (accepts filename and bytes).
    /// This callback should determine whether to process as text or binary based on the file extension.
    /// Returns an <see cref="ImportFileResult"/> with the parsed credentials and any per-item failures.
    /// A bare <see cref="List{ImportedCredential}"/> is implicitly convertible for callers that don't
    /// produce per-item failure data (e.g. CSV importers).
    /// </summary>
    [Parameter]
    public Func<string, byte[], Task<ImportFileResult>>? ProcessFileCallback { get; set; }

    /// <summary>
    /// Gets or sets the accepted file extensions (e.g., [".csv", ".avux"]).
    /// </summary>
    [Parameter]
    public string[] AcceptedFileExtensions { get; set; } = Array.Empty<string>();

    /// <summary>
    /// The effective accept attribute for the file input. Includes application/octet-stream as a fallback
    /// so that iOS and other mobile platforms allow selecting files with custom extensions (e.g. .avex, .avux, .1pux)
    /// that don't have a registered MIME type.
    /// </summary>
    private string FileAcceptTypes => string.Join(",", AcceptedFileExtensions) + ",application/octet-stream";

    private bool IsModalOpen { get; set; } = false;
    private bool IsImporting { get; set; } = false;
    private string? ImportError { get; set; }
    private string? ImportSuccessMessage { get; set; }
    private ImportStep CurrentStep { get; set; } = ImportStep.FileUpload;

    /// <summary>
    /// Child content which is shown in the modal popup. This can contain custom instructions.
    /// </summary>
    [Parameter]
    public RenderFragment ChildContent { get; set; } = null!;

    /// <summary>
    /// The imported credentials.
    /// </summary>
    private List<ImportedCredential> ImportedCredentials { get; set; } = new();

    private bool ExtractFavicons { get; set; } = true;
    private bool ImportFolders { get; set; } = true;
    private int DetectedFolderCount { get; set; } = 0;
    private List<string> DetectedFolderPaths { get; set; } = new();
    private bool ImportAttachments { get; set; } = true;
    private int DetectedAttachmentCount { get; set; } = 0;
    private long DetectedAttachmentsTotalSize { get; set; } = 0;
    private bool IsExtractingFavicons { get; set; }
    private int FaviconExtractionProgress { get; set; }
    private int TotalFaviconsToExtract { get; set; }
    private CancellationTokenSource? FaviconExtractionCancellation { get; set; }
    private Dictionary<string, byte[]> ExtractedFavicons { get; set; } = new();

    private bool IsSavingCredentials { get; set; }
    private int CredentialSaveProgress { get; set; }
    private int TotalCredentialsToSave { get; set; }
    private bool IsSyncingVault { get; set; }
    private Dictionary<string, Guid> ImportSessionLogoIds { get; set; } = new();

    private int DuplicateCredentialsCount { get; set; } = 0;

    // Fields for .avex encrypted file handling
    private string _decryptionPassword = string.Empty;
    private byte[]? _pendingAvexBytes;
    private string? _pendingAvexFilename;
    private bool _requiresPassword = false;

    // Fields for structured error reporting.
    // _errorPayload is the copyable diagnostic block that's rendered alongside ImportError.
    // _parseFailures captures per-item parse failures so the user can choose to import what worked.
    // _uploaded* preserve identity of the source file so the payload remains complete even
    // when the failure happens later (decrypt, DB save) than the original file upload.
    private string? _errorPayload;
    private List<ImportFailure> _parseFailures = new();
    private string? _uploadedFilename;
    private long _uploadedFileSize;

    // Toggle for the "Show details" affordance on the partial-failure callout.
    // Both the visible textarea and the "Copy details" button build their text from the same
    // helper so what the user sees is exactly what they get on the clipboard.
    private bool _showFailureDetails;

    // Cap for the inline preview of skipped items. The full list is always available via "Show details";
    // this just keeps the in-modal callout from growing unbounded when an export has many bad items.
    private const int FailedItemsPreviewLimit = 10;

    /// <summary>
    /// Sets the imported credentials and continues to the preview step.
    /// </summary>
    /// <param name="importedCredentials">The imported credentials.</param>
    public async Task SetImportedCredentials(List<ImportedCredential> importedCredentials)
    {
        ImportedCredentials = importedCredentials;

        // Continue to step 2.
        await HandleNextStep();
    }

    /// <summary>
    /// Sets pending encrypted file data and moves to password input step.
    /// </summary>
    /// <param name="encryptedBytes">The encrypted file bytes.</param>
    /// <param name="filename">The filename.</param>
    public void SetPendingEncryptedFile(byte[] encryptedBytes, string filename)
    {
        _pendingAvexBytes = encryptedBytes;
        _pendingAvexFilename = filename;
        _decryptionPassword = string.Empty;
        _requiresPassword = true;
        ImportError = null;
        CurrentStep = ImportStep.PasswordInput;
        IsImporting = false; // Stop the loading state
        StateHasChanged();
    }

    /// <summary>
    /// Called when a file is selected in the parent file upload step.
    /// </summary>
    public async Task FileSelected()
    {
        // If the file is selected, we can go to the preview step.
        await HandleNextStep();
    }

    /// <summary>
    /// Opens the import modal.
    /// </summary>
    protected virtual void OpenImportModal()
    {
        IsModalOpen = true;
        CurrentStep = ImportStep.FileUpload;
        StateHasChanged();
    }

    /// <summary>
    /// Closes the import modal.
    /// </summary>
    protected virtual void CloseModal()
    {
        IsModalOpen = false;
        CurrentStep = ImportStep.FileUpload;
        ImportError = null;
        ImportSuccessMessage = null;
        ImportedCredentials.Clear();
        _requiresPassword = false;
        _decryptionPassword = string.Empty;
        _pendingAvexBytes = null;
        _pendingAvexFilename = null;
        _errorPayload = null;
        _parseFailures = new();
        _showFailureDetails = false;
        _uploadedFilename = null;
        _uploadedFileSize = 0;
        StateHasChanged();
    }

    /// <summary>
    /// Handles the next step in the import process.
    /// </summary>
    protected virtual async Task HandleNextStep()
    {
        if (CurrentStep == ImportStep.Preview)
        {
            CurrentStep = ImportStep.Confirm;
        }
        else if (CurrentStep == ImportStep.Confirm)
        {
            await HandleModalConfirm();
        }
    }

    /// <summary>
    /// Handles the previous step in the import process.
    /// </summary>
    protected virtual void HandlePreviousStep()
    {
        if (CurrentStep == ImportStep.PasswordInput)
        {
            CurrentStep = ImportStep.FileUpload;
            _decryptionPassword = string.Empty;
            _pendingAvexBytes = null;
            _pendingAvexFilename = null;
            _requiresPassword = false;
        }
        else if (CurrentStep == ImportStep.Preview)
        {
            // If we came from password input (encrypted file), go back to password
            if (_pendingAvexBytes != null)
            {
                CurrentStep = ImportStep.PasswordInput;
            }
            else
            {
                CurrentStep = ImportStep.FileUpload;
            }
        }
        else if (CurrentStep == ImportStep.Confirm)
        {
            CurrentStep = ImportStep.Preview;
        }
    }

    /// <summary>
    /// Handles password keyboard events (Enter to submit, Escape to cancel).
    /// </summary>
    private async Task HandlePasswordKeyDown(KeyboardEventArgs e)
    {
        if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_decryptionPassword))
        {
            await HandleDecryptFile();
        }
        else if (e.Key == "Escape")
        {
            HandlePreviousStep();
        }
    }

    /// <summary>
    /// Handles decrypting the .avex file with the provided password.
    /// </summary>
    private async Task HandleDecryptFile()
    {
        if (_pendingAvexBytes == null)
        {
            return;
        }

        try
        {
            IsImporting = true;
            ImportError = null;
            StateHasChanged();
            await Task.Delay(50);

            // Decrypt the file to .avux bytes using AvexCryptoService
            var avuxBytes = await AvexCrypto.DecryptAvexAsync(_pendingAvexBytes, _decryptionPassword);

            // Process via the normal file handler as if it was an .avux upload
            // This ensures consistent handling and avoids EF tracking issues
            var decryptResult = await ProcessFileCallback!("decrypted.avux", avuxBytes);
            ImportedCredentials = decryptResult.Credentials;
            _parseFailures = decryptResult.FailedItems;

            // Clear password and encrypted data
            _decryptionPassword = string.Empty;
            _pendingAvexBytes = null;
            _pendingAvexFilename = null;
            _requiresPassword = false;

            IsImporting = false;

            // Move to preview step
            await DetectAndRemoveDuplicates();
            CurrentStep = ImportStep.Preview;
            StateHasChanged();
        }
        catch (InvalidOperationException ex) when (ex.Message.Contains("password may be incorrect"))
        {
            IsImporting = false;
            ImportError = Localizer["IncorrectPasswordError"];
            _errorPayload = null;
            StateHasChanged();
        }
        catch (Exception ex)
        {
            Logger.LogError(ex, "Error decrypting .avex file");
            IsImporting = false;
            SetImportErrorFromException(
                ex,
                _pendingAvexFilename ?? "decrypted.avux",
                _pendingAvexBytes?.LongLength ?? 0,
                defaultStage: ImportStage.Parse,
                fallbackMessage: Localizer["DecryptionErrorGeneric"]);
            StateHasChanged();
        }
    }

    /// <summary>
    /// Handles the modal confirm.
    /// </summary>
    protected virtual async Task HandleModalConfirm()
    {
        if (IsImporting)
        {
            return;
        }

        await InitializeImport();

        try
        {
            // Extract favicons for credentials that don't already have them
            if (ExtractFavicons)
            {
                await ExtractFaviconsForCredentials();
                if (FaviconExtractionCancellation?.Token.IsCancellationRequested == true)
                {
                    CleanupImport();
                    return;
                }
            }

            await ImportCredentialsToDatabase();
        }
        catch (Exception ex)
        {
            SetImportErrorFromException(
                ex,
                _uploadedFilename ?? string.Empty,
                _uploadedFileSize,
                defaultStage: ImportStage.Save);
            // Send the user back to the FileUpload step so the error block is visible and the
            // Confirm step's loading spinner doesn't paper over it.
            CurrentStep = ImportStep.FileUpload;
            Logger.LogError(ex, "Error during import");
        }
        finally
        {
            CleanupImport();
        }
    }

    /// <summary>
    /// Initializes the import.
    /// </summary>
    private async Task InitializeImport()
    {
        IsImporting = true;
        ImportError = null;
        ImportSuccessMessage = null;
        StateHasChanged();

        // Let UI update to start showing the loading indicator
        await Task.Delay(50);
    }

    /// <summary>
    /// Cleans up the import.
    /// </summary>
    private void CleanupImport()
    {
        IsImporting = false;
        IsExtractingFavicons = false;
        IsSavingCredentials = false;
        IsSyncingVault = false;
        StateHasChanged();
    }

    /// <summary>
    /// Extracts favicons for credentials. Delegates dedup-by-domain and batched server-side
    /// fetching to <see cref="FaviconService"/>; this method only handles UI progress reporting
    /// and cancellation.
    /// </summary>
    private async Task ExtractFaviconsForCredentials()
    {
        IsExtractingFavicons = true;
        FaviconExtractionProgress = 0;
        ExtractedFavicons.Clear();
        FaviconExtractionCancellation = new CancellationTokenSource();
        StateHasChanged();

        // Only extract favicons for credentials that don't already have them and have URLs.
        var urls = ImportedCredentials
            .Where(c => c.FaviconBytes == null)
            .Select(c => c.ServiceUrls?.FirstOrDefault())
            .Where(u => !string.IsNullOrEmpty(u))
            .Cast<string>()
            .ToList();

        // The dialog reports progress as "domains processed" rather than "credentials processed",
        // since the FaviconService dedupes by domain and that's the actual unit of fetch work.
        TotalFaviconsToExtract = urls
            .Select(u => FaviconService.TryNormalizeDomain(u, out var d) ? d : null)
            .Where(d => d != null)
            .Distinct()
            .Count();

        // Task.Delay(1) inside the progress callback lets Blazor actually paint between batches.
        var progress = new Progress<int>(async count =>
        {
            FaviconExtractionProgress = count;
            StateHasChanged();
            await Task.Delay(1);
        });

        var result = await FaviconService.ExtractBulkAsync(urls, progress, FaviconExtractionCancellation.Token);
        foreach (var kvp in result.Favicons)
        {
            ExtractedFavicons[kvp.Key] = kvp.Value;
        }

        if (result.Status == FaviconService.BulkExtractStatus.RateLimited)
        {
            // Rate limit error occurred. Silently skip the favicons that couldn't be extracted.
        }

        // Final update to show 100% completion before transitioning to next phase.
        FaviconExtractionProgress = TotalFaviconsToExtract;
        StateHasChanged();
        await Task.Delay(50);

        IsExtractingFavicons = false;
        StateHasChanged();
    }

    /// <summary>
    /// Imports the items to the database.
    /// </summary>
    private async Task ImportCredentialsToDatabase()
    {
        // Build folder mapping if folder import is enabled
        Dictionary<string, Guid>? folderNameToId = null;
        if (ImportFolders && DetectedFolderCount > 0)
        {
            folderNameToId = await CreateOrGetFolders();
        }

        // If the user opted out of importing attachments, strip them before conversion
        // so they don't end up persisted on the resulting items.
        if (!ImportAttachments)
        {
            foreach (var credential in ImportedCredentials)
            {
                credential.Attachments = null;
            }
        }

        var items = BaseImporter.ConvertToItem(ImportedCredentials, folderNameToId);

        // Clear logo tracking for this import session
        ImportSessionLogoIds.Clear();

        // Show saving credentials progress
        IsSavingCredentials = true;
        CredentialSaveProgress = 0;
        TotalCredentialsToSave = items.Count;
        StateHasChanged();

        const int updateEveryNItems = 10;
        for (var itemIndex = 0; itemIndex < items.Count; itemIndex++)
        {
            var item = items[itemIndex];
            try
            {
                await ProcessSingleItem(item);
            }
            catch (Exception ex)
            {
                // Re-throw with item context so the outer catch can build an actionable payload
                // identifying exactly which item failed to save into the local vault.
                throw new ImportException(
                    ImportStage.Save,
                    $"Failed to save item #{itemIndex + 1} of {items.Count} (\"{item.Name}\"): {ex.Message}",
                    ex);
            }

            CredentialSaveProgress++;

            if (CredentialSaveProgress % updateEveryNItems == 0)
            {
                StateHasChanged();
                await Task.Delay(1);
            }
        }

        StateHasChanged();
        await Task.Delay(50);

        IsSavingCredentials = false;
        IsSyncingVault = true;
        StateHasChanged();

        var success = await DbService.SaveDatabaseAsync();
        if (success)
        {
            GlobalNotificationService.AddSuccessMessage(string.Format(Localizer["ImportSuccessMessage"], ImportedCredentials.Count));
            NavigationManager.NavigateTo("/items");
            return;
        }

        // Save failed. Close the import modal and let the user see the error via the global notification bar on the underlying page.
        CloseModal();
    }

    /// <summary>
    /// Creates folders that don't exist and returns a mapping of folder paths to folder IDs.
    /// Handles hierarchical folder structures by creating parent folders first.
    /// </summary>
    private async Task<Dictionary<string, Guid>> CreateOrGetFolders()
    {
        var folderPathToId = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
        var context = await DbService.GetDbContextAsync();

        // Load all existing folders with their parent relationships
        var existingFolders = await context.Folders
            .Where(f => !f.IsDeleted)
            .ToListAsync();

        // Build a lookup of existing folders by full path
        var existingFoldersByPath = new Dictionary<string, Folder>(StringComparer.OrdinalIgnoreCase);
        foreach (var folder in existingFolders)
        {
            var fullPath = GetFolderFullPath(folder, existingFolders);
            existingFoldersByPath[fullPath] = folder;
        }

        // Process folders in order (they're already sorted by depth, parents before children)
        foreach (var folderPath in DetectedFolderPaths)
        {
            // Check if folder already exists at this path
            if (existingFoldersByPath.TryGetValue(folderPath, out var existingFolder))
            {
                folderPathToId[folderPath] = existingFolder.Id;
            }
            else
            {
                // Extract folder name from the path (last segment after last /)
                var lastSlashIndex = folderPath.LastIndexOf('/');
                var folderName = lastSlashIndex >= 0 ? folderPath.Substring(lastSlashIndex + 1) : folderPath;

                // Determine parent folder ID
                Guid? parentFolderId = null;
                if (lastSlashIndex >= 0)
                {
                    // Parent path is everything before the last /
                    var parentPath = folderPath.Substring(0, lastSlashIndex);
                    if (folderPathToId.TryGetValue(parentPath, out var parentId))
                    {
                        parentFolderId = parentId;
                    }
                }

                // Create new folder with parent relationship. Skip per-folder background sync —
                // the final DbService.SaveDatabaseAsync() at the end of the import covers all mutations.
                var newFolderId = await FolderService.CreateAsync(folderName, parentFolderId, syncToServer: false);
                folderPathToId[folderPath] = newFolderId;
            }
        }

        return folderPathToId;
    }

    /// <summary>
    /// Collects all unique folder paths from imported credentials, including intermediate paths.
    /// For example, if credentials have "Root/Business/Banking" and "Root/Personal", this returns:
    /// ["Root", "Root/Business", "Root/Business/Banking", "Root/Personal"] in hierarchical order.
    /// </summary>
    private List<string> CollectHierarchicalFolderPaths(List<ImportedCredential> credentials)
    {
        var folderPathSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        var folderPathsWithDepth = new List<(string path, int depth)>();

        foreach (var credential in credentials)
        {
            if (string.IsNullOrWhiteSpace(credential.FolderPath))
            {
                continue;
            }

            // Parse into individual folder names
            var parts = BaseImporter.ParseFolderPath(credential.FolderPath);

            // Build all hierarchical paths (parent, parent/child, parent/child/grandchild, etc.)
            for (int i = 0; i < parts.Count; i++)
            {
                var path = string.Join("/", parts.Take(i + 1));

                // Only add if we haven't seen this path before (case-insensitive)
                if (!folderPathSet.Contains(path))
                {
                    folderPathSet.Add(path);
                    folderPathsWithDepth.Add((path, i)); // i is the depth
                }
            }
        }

        // Sort by depth first (so parents are created before children), then by path
        return folderPathsWithDepth
            .OrderBy(f => f.depth)
            .ThenBy(f => f.path, StringComparer.OrdinalIgnoreCase)
            .Select(f => f.path)
            .ToList();
    }

    /// <summary>
    /// Gets the full path of a folder by traversing up the parent hierarchy.
    /// </summary>
    /// <param name="folder">The folder to get the path for.</param>
    /// <param name="allFolders">All folders in the database.</param>
    /// <returns>The full path (e.g., "Root/Business/Banking").</returns>
    private string GetFolderFullPath(Folder folder, List<Folder> allFolders)
    {
        var path = new List<string>();
        var current = folder;

        while (current != null)
        {
            path.Insert(0, current.Name ?? string.Empty);

            if (current.ParentFolderId.HasValue)
            {
                current = allFolders.FirstOrDefault(f => f.Id == current.ParentFolderId.Value);
            }
            else
            {
                current = null;
            }
        }

        return string.Join("/", path);
    }

    /// <summary>
    /// Processes a single item and handles logo/favicon assignment.
    /// </summary>
    /// <param name="item">The item to process.</param>
    private async Task ProcessSingleItem(Item item)
    {
        // Find the corresponding imported credential
        var importedCredential = ImportedCredentials.FirstOrDefault(c => c.ServiceName == item.Name);

        // Check if the credential has embedded favicon bytes (from .avux or other sources)
        if (importedCredential?.FaviconBytes != null)
        {
            // Get URL domain for deduplication key
            var url = ItemService.GetFieldValue(item, FieldKey.LoginUrl);
            string domain;

            if (!string.IsNullOrEmpty(url))
            {
                try
                {
                    domain = new Uri(url).Host.ToLowerInvariant();
                    if (domain.StartsWith("www."))
                    {
                        domain = domain[4..];
                    }
                }
                catch
                {
                    // Invalid URL, use item name as fallback
                    domain = (item.Name ?? "unknown").ToLowerInvariant();
                }
            }
            else
            {
                // No URL, use item name as fallback
                domain = (item.Name ?? "unknown").ToLowerInvariant();
            }

            // Check if we already created a logo for this domain in this import session
            if (ImportSessionLogoIds.TryGetValue(domain, out var sessionLogoId))
            {
                item.LogoId = sessionLogoId;
            }
            else
            {
                var context = await DbService.GetDbContextAsync();

                // Lookup ignores IsDeleted because Logos.Source is UNIQUE — a soft-deleted row
                // with the same domain still occupies the slot and would block a new insert.
                var existingLogo = await context.Logos.FirstOrDefaultAsync(l => l.Source == domain);
                if (existingLogo != null && !existingLogo.IsDeleted && existingLogo.FileData is { Length: > 0 })
                {
                    // Healthy existing logo — reuse without overwriting.
                    item.LogoId = existingLogo.Id;
                    ImportSessionLogoIds[domain] = existingLogo.Id;
                }
                else if (existingLogo != null)
                {
                    // Refill a soft-deleted or pruner-emptied row with the imported bytes so the
                    // new item gets a usable logo, instead of inheriting an empty blob.
                    var nowUtc = DateTime.UtcNow;
                    existingLogo.IsDeleted = false;
                    existingLogo.FileData = importedCredential.FaviconBytes;
                    existingLogo.FetchedAt = nowUtc;
                    existingLogo.UpdatedAt = nowUtc;
                    item.LogoId = existingLogo.Id;
                    ImportSessionLogoIds[domain] = existingLogo.Id;
                }
                else
                {
                    var logo = new AliasClientDb.Logo
                    {
                        Id = Guid.NewGuid(),
                        Source = domain,
                        FileData = importedCredential.FaviconBytes,
                        FetchedAt = DateTime.UtcNow,
                        CreatedAt = DateTime.UtcNow,
                        UpdatedAt = DateTime.UtcNow,
                    };
                    context.Logos.Add(logo);
                    item.LogoId = logo.Id;
                    ImportSessionLogoIds[domain] = logo.Id;
                }
            }
        }
        else
        {
            // No embedded favicon - try to use extracted favicons
            var url = ItemService.GetFieldValue(item, FieldKey.LoginUrl);
            if (!string.IsNullOrEmpty(url))
            {
                try
                {
                    // Extract and normalize domain for deduplication
                    var domain = new Uri(url).Host.ToLowerInvariant();
                    if (domain.StartsWith("www."))
                    {
                        domain = domain[4..];
                    }

                    // First check if we already created a logo for this domain in this import session
                    if (ImportSessionLogoIds.TryGetValue(domain, out var sessionLogoId))
                    {
                        item.LogoId = sessionLogoId;
                    }
                    else
                    {
                        var context = await DbService.GetDbContextAsync();

                        var existingLogo = await context.Logos.FirstOrDefaultAsync(l => l.Source == domain);
                        if (existingLogo != null && !existingLogo.IsDeleted && existingLogo.FileData is { Length: > 0 })
                        {
                            item.LogoId = existingLogo.Id;
                            ImportSessionLogoIds[domain] = existingLogo.Id;
                        }
                        else if (ExtractedFavicons.TryGetValue(domain, out var favicon))
                        {
                            var nowUtc = DateTime.UtcNow;
                            if (existingLogo != null)
                            {
                                existingLogo.IsDeleted = false;
                                existingLogo.FileData = favicon;
                                existingLogo.FetchedAt = nowUtc;
                                existingLogo.UpdatedAt = nowUtc;
                                item.LogoId = existingLogo.Id;
                                ImportSessionLogoIds[domain] = existingLogo.Id;
                            }
                            else
                            {
                                var logo = new AliasClientDb.Logo
                                {
                                    Id = Guid.NewGuid(),
                                    Source = domain,
                                    FileData = favicon,
                                    FetchedAt = nowUtc,
                                    CreatedAt = nowUtc,
                                    UpdatedAt = nowUtc,
                                };
                                context.Logos.Add(logo);
                                item.LogoId = logo.Id;
                                ImportSessionLogoIds[domain] = logo.Id;
                            }
                        }
                    }
                }
                catch (Exception ex)
                {
                    // Log error but continue import - favicon is not critical
                    Logger.LogWarning(ex, "Failed to process favicon for item {ItemName}", item.Name);
                }
            }
        }

        await ItemService.InsertEntryAsync(item, false, false);
    }

    /// <summary>
    /// Handles the file upload.
    /// </summary>
    private async Task HandleFileUpload(InputFileChangeEventArgs e)
    {
        if (string.IsNullOrEmpty(e.File.Name))
        {
            ImportError = Localizer["ImportErrorInvalidFile"];
            return;
        }

        // Validate file extension against accepted extensions
        var fileExtension = Path.GetExtension(e.File.Name);
        if (!AcceptedFileExtensions.Any(ext => ext.Equals(fileExtension, StringComparison.OrdinalIgnoreCase)))
        {
            var supportedFormats = string.Join(", ", AcceptedFileExtensions);
            ImportError = Localizer["ImportErrorUnsupportedFormat", supportedFormats];
            return;
        }

        // Capture the uploaded file's identity so later failure paths (e.g. database save) can
        // still surface filename + size in the copyable error payload.
        _uploadedFilename = e.File.Name;
        _uploadedFileSize = e.File.Size;

        try
        {
            IsImporting = true;
            StateHasChanged();

            // Limit file size fixed in code to 1024MB.
            // This is a browser-memory safety ceiling, not a server/infra limit. The file
            // is parsed entirely client-side and never uploaded. Server upload limits for the
            // resulting vault sync are controlled separately via the MAX_UPLOAD_SIZE_MB env var.
            const int maxFileSizeMb = 1024;
            var maxFileSize = maxFileSizeMb * 1024 * 1024;
            if (e.File.Size > maxFileSize)
            {
                ImportError = Localizer["ImportErrorFileTooLarge", maxFileSizeMb];
                return;
            }

            await using var stream = e.File.OpenReadStream(maxAllowedSize: maxFileSize);

            if (ProcessFileCallback == null)
            {
                ImportError = "No file processor configured";
                return;
            }

            // Read file bytes
            using var memoryStream = new MemoryStream();
            await stream.CopyToAsync(memoryStream);
            var fileBytes = memoryStream.ToArray();

            // Check if this is an encrypted .avex file
            if (e.File.Name.EndsWith(".avex", StringComparison.OrdinalIgnoreCase))
            {
                // Encrypted file - trigger password input step
                SetPendingEncryptedFile(fileBytes, e.File.Name);
                return; // Password step will handle the rest
            }

            // Call callback for file processing
            var processingTask = ProcessFileCallback(e.File.Name, fileBytes);
            var delayTask = Task.Delay(500);
            await Task.WhenAll(processingTask, delayTask);

            var result = await processingTask;
            ImportedCredentials = result.Credentials;
            _parseFailures = result.FailedItems;

            // If password is required, the callback will handle setting the step to PasswordInput
            // So we only proceed to preview if no password is required
            if (!_requiresPassword)
            {
                // Detect and remove duplicates before showing the preview
                await DetectAndRemoveDuplicates();
                CurrentStep = ImportStep.Preview;
            }
            // else: the step was already set to PasswordInput by SetPendingEncryptedFile()
        }
        catch (Exception ex)
        {
            SetImportErrorFromException(ex, e.File.Name, e.File.Size, defaultStage: ImportStage.Parse);
            Logger.LogError(ex, "Error processing {ServiceName} export file", ServiceName);
        }
        finally
        {
            // Only set IsImporting to false if we're not waiting for password
            if (!_requiresPassword)
            {
                IsImporting = false;
            }
            StateHasChanged();
        }
    }

    /// <summary>
    /// Detects and removes duplicates from the import list, and also detects folders.
    /// Duplicates are identified by matching title, folder path, username, password, and notes (when present).
    /// For each field: if both items have values, they must match; if both are null/empty, they match; otherwise no match.
    /// </summary>
    private async Task DetectAndRemoveDuplicates()
    {
        var context = await DbService.GetDbContextAsync();

        // Load all items with folder navigation included
        var existingItems = await context.Items
            .Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
                .ThenInclude(fv => fv.FieldDefinition)
            .Include(x => x.Folder)
            .Where(x => !x.IsDeleted && x.DeletedAt == null)
            .ToListAsync();

        // Load all folders to build full hierarchical paths
        var allFolders = await context.Folders
            .Where(f => !f.IsDeleted)
            .ToListAsync();

        var duplicates = ImportedCredentials
            .Where(imported => existingItems.Any(existing => IsDuplicateItem(existing, imported, allFolders)))
            .ToList();

        DuplicateCredentialsCount = duplicates.Count;

        // Remove duplicates from the import list
        ImportedCredentials = ImportedCredentials.Except(duplicates).ToList();

        // Detect folders in the import (hierarchical)
        DetectedFolderPaths = CollectHierarchicalFolderPaths(ImportedCredentials);
        DetectedFolderCount = DetectedFolderPaths.Count;

        // Detect attachments in the import (count and total blob size)
        DetectedAttachmentCount = 0;
        DetectedAttachmentsTotalSize = 0;
        foreach (var credential in ImportedCredentials)
        {
            if (credential.Attachments == null)
            {
                continue;
            }

            foreach (var attachment in credential.Attachments)
            {
                DetectedAttachmentCount++;
                DetectedAttachmentsTotalSize += attachment.Blob?.LongLength ?? 0;
            }
        }
    }

    /// <summary>
    /// Determines if an imported credential is a duplicate of an existing item.
    /// Compares title, folder path, username, password, and notes.
    /// </summary>
    /// <param name="existing">The existing item in the vault.</param>
    /// <param name="imported">The imported credential to check.</param>
    /// <param name="allFolders">All folders for building hierarchical paths.</param>
    /// <returns>True if the items are duplicates, false otherwise.</returns>
    private bool IsDuplicateItem(Item existing, ImportedCredential imported, List<Folder> allFolders)
    {
        // Title must match (case-insensitive)
        if (existing.Name == null || !existing.Name.Equals(imported.ServiceName, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        // Folder path must match (case-insensitive)
        var existingFolderPath = existing.Folder != null ? GetFolderFullPath(existing.Folder, allFolders) : string.Empty;
        var importedFolderPath = imported.FolderPath ?? string.Empty;
        if (!existingFolderPath.Equals(importedFolderPath, StringComparison.OrdinalIgnoreCase))
        {
            return false;
        }

        // Username must match
        if (!FieldValuesMatch(ItemService.GetFieldValue(existing, FieldKey.LoginUsername), imported.Username))
        {
            return false;
        }

        // Password must match
        if (!FieldValuesMatch(ItemService.GetFieldValue(existing, FieldKey.LoginPassword), imported.Password))
        {
            return false;
        }

        // Notes must match (using contains check for compatibility with possible appended custom fields)
        if (!NotesFieldsMatch(ItemService.GetFieldValue(existing, FieldKey.NotesContent), imported.Notes))
        {
            return false;
        }

        return true;
    }

    /// <summary>
    /// Compares two field values for exact match (case-insensitive).
    /// Both empty/null values are considered a match.
    /// </summary>
    /// <param name="existingValue">The existing field value.</param>
    /// <param name="importedValue">The imported field value.</param>
    /// <returns>True if the values match, false otherwise.</returns>
    private static bool FieldValuesMatch(string? existingValue, string? importedValue)
    {
        bool existingHasValue = !string.IsNullOrEmpty(existingValue);
        bool importedHasValue = !string.IsNullOrEmpty(importedValue);

        // If both have values, they must match (case-insensitive)
        if (existingHasValue && importedHasValue)
        {
            return existingValue!.Equals(importedValue, StringComparison.OrdinalIgnoreCase);
        }

        // If both are empty/null, they match
        if (!existingHasValue && !importedHasValue)
        {
            return true;
        }

        // If only one has a value, they don't match
        return false;
    }

    /// <summary>
    /// Compares notes fields using a contains check for compatibility with appended custom fields.
    /// Some importers append custom fields to notes, so we check if imported notes are contained
    /// in existing notes rather than requiring an exact match.
    /// </summary>
    /// <param name="existingNotes">The existing notes value.</param>
    /// <param name="importedNotes">The imported notes value.</param>
    /// <returns>True if the notes match, false otherwise.</returns>
    private static bool NotesFieldsMatch(string? existingNotes, string? importedNotes)
    {
        bool existingHasValue = !string.IsNullOrEmpty(existingNotes);
        bool importedHasValue = !string.IsNullOrEmpty(importedNotes);

        // If both are empty/null, they match
        if (!existingHasValue && !importedHasValue)
        {
            return true;
        }

        // If only one has a value, they don't match
        if (existingHasValue != importedHasValue)
        {
            return false;
        }

        // Both have values - check if imported notes are contained in existing notes
        // This handles cases where importers append custom fields to notes
        return existingNotes!.Contains(importedNotes!, StringComparison.OrdinalIgnoreCase);
    }

    /// <summary>
    /// Calculates the progress percentage based on the current step in the import process.
    /// </summary>
    /// <returns>The progress percentage as an integer.</returns>
    private int GetProgressPercentage()
    {
        return (int)CurrentStep * 100 / (Enum.GetValues(typeof(ImportStep)).Length - 1);
    }

    private void CancelFaviconExtraction()
    {
        FaviconExtractionCancellation?.Cancel();
        IsExtractingFavicons = false;
        StateHasChanged();
    }

    /// <summary>
    /// Formats a byte count as a human-readable size string (e.g. "3.9 MB").
    /// </summary>
    /// <param name="bytes">The number of bytes.</param>
    /// <returns>The formatted size string.</returns>
    private static string FormatBytes(long bytes)
    {
        string[] units = { "B", "KB", "MB", "GB", "TB" };
        double size = bytes;
        int unitIndex = 0;

        while (size >= 1024 && unitIndex < units.Length - 1)
        {
            size /= 1024;
            unitIndex++;
        }

        return unitIndex == 0
            ? $"{size:0} {units[unitIndex]}"
            : string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0.#} {1}", size, units[unitIndex]);
    }

    /// <summary>
    /// Renders the red error block with an intro line, the copyable diagnostic payload (when present),
    /// and a "Copy details" button. Shared between the FileUpload and PasswordInput steps so both
    /// failure paths surface the same actionable information.
    /// </summary>
    /// <returns>The render fragment for the error block.</returns>
    private RenderFragment RenderImportError() => __builder =>
    {
        <div class="mb-4 p-4 text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
            <p>@ImportError</p>
            @if (!string.IsNullOrEmpty(_errorPayload))
            {
                <p class="mt-3 mb-2 text-sm">@Localizer["ImportErrorDetailsIntro"]</p>
                <pre class="text-xs whitespace-pre-wrap break-words bg-red-50 dark:bg-red-50 p-2 rounded border border-red-200 dark:border-red-300 select-all">@_errorPayload</pre>
                <div class="mt-2">
                    <button type="button" @onclick="CopyErrorPayload" class="text-xs underline font-medium hover:no-underline">
                        @Localizer["ImportErrorCopyDetailsButton"]
                    </button>
                </div>
            }
        </div>
    };

    /// <summary>
    /// Captures an exception as a localized header message plus a copyable diagnostic payload.
    /// Honours <see cref="ImportException.Stage"/> when present so the payload reports the most
    /// specific stage; falls back to the caller-provided <paramref name="defaultStage"/> otherwise.
    /// </summary>
    /// <param name="ex">The exception to capture.</param>
    /// <param name="filename">The source filename for context.</param>
    /// <param name="fileSize">The source file size in bytes.</param>
    /// <param name="defaultStage">The stage to report when the exception isn't an ImportException.</param>
    /// <param name="fallbackMessage">Optional header message override (e.g. decryption-specific copy).</param>
    private void SetImportErrorFromException(
        Exception ex,
        string filename,
        long fileSize,
        ImportStage defaultStage,
        string? fallbackMessage = null)
    {
        var stage = ex is ImportException importEx ? importEx.Stage : defaultStage;
        ImportError = fallbackMessage ?? Localizer["ImportErrorGeneric"];
        _errorPayload = BuildErrorPayload(filename, fileSize, stage, ex);
    }

    /// <summary>
    /// Formats a copyable diagnostic block describing a fatal import failure. The block is intentionally
    /// English-only across all locales so users can paste it directly into a GitHub issue or support
    /// message without translation noise, and so maintainers can grep stack traces.
    /// </summary>
    /// <param name="filename">The source filename.</param>
    /// <param name="fileSize">The source file size in bytes.</param>
    /// <param name="stage">The pipeline stage where the failure happened.</param>
    /// <param name="ex">The exception that was caught.</param>
    /// <returns>The formatted payload string.</returns>
    private string BuildErrorPayload(string filename, long fileSize, ImportStage stage, Exception ex)
    {
        var sb = new System.Text.StringBuilder();
        sb.Append("Service: ").AppendLine(ServiceName);
        if (!string.IsNullOrEmpty(filename))
        {
            sb.Append("File: ").Append(filename);
            if (fileSize > 0)
            {
                sb.Append(" (").Append(FormatBytes(fileSize)).Append(')');
            }

            sb.AppendLine();
        }

        sb.Append("Stage: ").AppendLine(stage.ToString().ToLowerInvariant());
        sb.Append("Error: ").Append(ex.GetType().Name).Append(" — ").AppendLine(ex.Message);

        // Surface the inner exception's message when wrapping happened (e.g. JsonException inside ImportException),
        // since that's where the most specific diagnostic typically lives.
        if (ex.InnerException != null && !string.Equals(ex.InnerException.Message, ex.Message, StringComparison.Ordinal))
        {
            sb.Append("Inner: ").Append(ex.InnerException.GetType().Name).Append(" — ").AppendLine(ex.InnerException.Message);
        }

        return sb.ToString().TrimEnd();
    }

    /// <summary>
    /// Copies the current error payload to the clipboard.
    /// </summary>
    private async Task CopyErrorPayload()
    {
        if (!string.IsNullOrEmpty(_errorPayload))
        {
            await JsInteropService.CopyToClipboard(_errorPayload);
        }
    }

    /// <summary>
    /// Builds the full diagnostic text shown in the "Show details" textarea and copied to the
    /// clipboard. Kept in one place so what the user sees is exactly what they paste into a bug report.
    /// </summary>
    private string BuildFailureDetailsText()
    {
        if (_parseFailures.Count == 0)
        {
            return string.Empty;
        }

        var sb = new System.Text.StringBuilder();
        sb.Append("Service: ").AppendLine(ServiceName);
        if (!string.IsNullOrEmpty(_uploadedFilename))
        {
            sb.Append("File: ").Append(_uploadedFilename);
            if (_uploadedFileSize > 0)
            {
                sb.Append(" (").Append(FormatBytes(_uploadedFileSize)).Append(')');
            }

            sb.AppendLine();
        }

        sb.Append(_parseFailures.Count).AppendLine(" item(s) could not be parsed and were skipped:");
        foreach (var failure in _parseFailures)
        {
            sb.Append("- [").Append(failure.Index).Append("] ").Append(failure.ExceptionType).Append(": ").AppendLine(failure.Message);
        }

        return sb.ToString().TrimEnd();
    }

    /// <summary>
    /// Toggles visibility of the failure-details textarea.
    /// </summary>
    private void ToggleFailureDetails() => _showFailureDetails = !_showFailureDetails;

    /// <summary>
    /// Copies the failure-details block to the clipboard.
    /// </summary>
    private async Task CopyFailedItemsDetails()
    {
        var text = BuildFailureDetailsText();
        if (!string.IsNullOrEmpty(text))
        {
            await JsInteropService.CopyToClipboard(text);
        }
    }
}
