using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design;

/// <summary>
/// Code-behind for the TransportImport wizard (Transport feature).
///
/// Five-step state machine:
///   <list type="number">
///     <item><see cref="ImportWizardStep.Upload"/> — read bundle bytes, attempt
///           a passphrase-less <see cref="IBundleImporter.LoadAsync"/>; if the
///           bundle is encrypted, advance to Step 2 without yet opening a session.</item>
///     <item><see cref="ImportWizardStep.Passphrase"/> — collect the passphrase
///           and retry LoadAsync; 3-strike lockout per the configured
///           <see cref="TransportOptions.MaxUnlockAttemptsPerSession"/>.</item>
///     <item><see cref="ImportWizardStep.Diff"/> — render <see cref="ImportPreview"/>
///           items, collect <see cref="ImportResolution"/> per Modified item; Apply
///           is blocked while any <see cref="ConflictKind.Blocker"/> remains.</item>
///     <item><see cref="ImportWizardStep.Confirm"/> — type-the-environment-name
///           guard prevents accidental cross-cluster overwrites.</item>
///     <item><see cref="ImportWizardStep.Result"/> — render Apply result + audit
///           drill-in link; on <see cref="SemanticValidationException"/>, surface
///           the error list and allow returning to Step 3.</item>
///   </list>
///
/// The page is gated on <c>RequireAdmin</c> — Import touches central configuration
/// globally and must not be available to Design-only or Deployment-only users.
///
/// Cached bundle bytes: because <see cref="IBundleImporter.LoadAsync"/> currently
/// peeks the manifest by attempting decryption, encrypted bundles require two
/// LoadAsync invocations. We previously cached the raw bytes in a
/// <c>byte[] _bundleBytes</c> field, which buffered the full upload (default cap
/// 100 MB) in the component's per-circuit state — multiplied across concurrent
/// operator sessions, that produced real central-node memory pressure. The
/// bytes are now streamed once to a per-session temp file under
/// <c>Path.GetTempPath()/scadabridge-transport-staging/</c> and only the path is
/// retained on the component. The file is deleted on Back-to-Upload / Reset /
/// successful Apply / component Dispose, so an abandoned wizard does not leak
/// staged bundle plaintext beyond circuit teardown.
/// </summary>
public partial class TransportImport : ComponentBase, IDisposable
{
    public enum ImportWizardStep
    {
        Upload = 1,
        Passphrase = 2,
        Diff = 3,
        Confirm = 4,
        Result = 5,
    }

    // ---- Injected services ----
    [Inject] private IBundleImporter BundleImporter { get; set; } = default!;
    [Inject] private NavigationManager Nav { get; set; } = default!;
    [Inject] private AuthenticationStateProvider Auth { get; set; } = default!;
    [Inject] private IOptions<TransportOptions> Options { get; set; } = default!;
    [Inject] private IAuditService AuditService { get; set; } = default!;
    [Inject] private ScadaBridgeDbContext DbContext { get; set; } = default!;

    // The Map step needs the destination environment's sites + each
    // site's connections to populate the "map to existing target" dropdowns.
    [Inject] private ISiteRepository SiteRepo { get; set; } = default!;

    // ---- Wizard state ----
    private ImportWizardStep _step = ImportWizardStep.Upload;
    private string? _errorMessage;

    // ---- Session + cached bundle path ----
    // The upload is streamed to a per-session temp file and only
    // the path is retained on the component, so we don't hold an entire bundle
    // (up to MaxBundleSizeMb, default 100 MB) in per-circuit memory across the
    // wizard's lifetime. The file is deleted on every wizard reset path and on
    // component disposal so an abandoned wizard cannot leak staged plaintext
    // beyond circuit teardown.
    private string? _bundleTempPath;
    private BundleSession? _session;
    private bool _uploadInProgress;

    // Staging directory for in-flight bundle uploads. Lives under the system
    // temp directory rather than wwwroot/ because the file is never served to
    // a browser — it is only read by the in-process IBundleImporter.
    private static readonly string StagingDir =
        Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");

    // ---- Step 2: passphrase ----
    private string _passphrase = string.Empty;
    private int _failedUnlockAttempts;

    // ---- Step 3: preview + resolutions ----
    private ImportPreview? _preview;
    // Keyed by (EntityType, Name) — matches BundleImporter.ApplyAsync's lookup.
    private Dictionary<(string EntityType, string Name), ImportResolution>? _resolutions;

    // ---- Step 3 (Map sub-section): name mapping ----
    // The sentinel dropdown value for "Create new" — empty string can't collide
    // with a real SiteIdentifier / connection Name (both are non-empty).
    private const string CreateNewValue = "";

    // Destination environment's sites, loaded once when the preview carries any
    // required mappings.
    private IReadOnlyList<Site> _targetSites = Array.Empty<Site>();

    // Per-target-site connections, loaded lazily and cached by SiteIdentifier.
    private readonly Dictionary<string, IReadOnlyList<DataConnection>> _targetConnections = new();

    // Operator's site choices, keyed by SourceSiteIdentifier. Value is the chosen
    // target SiteIdentifier, or CreateNewValue ("") for "Create new". Seeded from
    // each RequiredSiteMapping.AutoMatchTargetIdentifier.
    private readonly Dictionary<string, string> _siteChoices = new();

    // Operator's connection choices, keyed by (SourceSiteIdentifier, SourceConnectionName).
    // Value is the chosen target connection Name, or CreateNewValue for "Create new".
    private readonly Dictionary<(string SourceSite, string SourceConn), string> _connectionChoices = new();

    // ---- Step 4: confirm ----
    private string _confirmEnvironmentText = string.Empty;

    // ---- Step 5: apply result ----
    private bool _applyInProgress;
    private ImportResult? _result;
    private IReadOnlyList<string>? _validationErrors;

    // ============================================================
    // Step 1 — Upload
    // ============================================================

    /// <summary>
    /// Buffers the selected file, enforces the configured size cap, then calls
    /// <see cref="IBundleImporter.LoadAsync"/> with no passphrase to peek the
    /// manifest. Encrypted bundles surface as <see cref="ArgumentException"/>,
    /// which we catch and use to advance to Step 2 — the session is opened on
    /// the second LoadAsync call once the passphrase is provided.
    /// </summary>
    private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
    {
        _errorMessage = null;
        _uploadInProgress = true;
        _session = null;
        DeleteBundleTempFile();
        try
        {
            var maxBytes = Options.Value.MaxBundleSizeMb * 1024L * 1024L;
            if (e.File.Size > maxBytes)
            {
                _errorMessage = $"Bundle exceeds the maximum allowed size of {Options.Value.MaxBundleSizeMb} MB.";
                return;
            }

            // Stream the upload directly to a per-session temp
            // file so the central node's working set is bounded by the
            // FileStream buffer (~80 KB) rather than the full bundle bytes.
            // OpenReadStream's MaxAllowedSize defaults to 500_000 bytes — bump
            // it to the configured cap so the read doesn't throw before we get
            // to the importer's own length check.
            Directory.CreateDirectory(StagingDir);
            _bundleTempPath = Path.Combine(StagingDir, $"{Guid.NewGuid():N}.scadabundle");
            using (var fileStream = e.File.OpenReadStream(maxBytes))
            await using (var dest = new FileStream(
                _bundleTempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
                bufferSize: 81920, useAsync: true))
            {
                await fileStream.CopyToAsync(dest);
            }

            await TryLoadAsync(passphrase: null);
        }
        catch (ArgumentException)
        {
            // Encrypted bundle, no passphrase yet — expected. The wizard
            // advances to the passphrase step when the user clicks Next.
        }
        catch (Exception ex)
        {
            _errorMessage = $"Failed to read bundle: {ex.Message}";
        }
        finally
        {
            _uploadInProgress = false;
        }
    }

    /// <summary>
    /// Attempts to open a <see cref="BundleSession"/> from the cached bytes with
    /// the given passphrase. On <see cref="ArgumentException"/> (encrypted bundle
    /// with no passphrase) leaves the wizard's step caller to advance to the
    /// passphrase step. Wrong-passphrase failures surface as
    /// <see cref="CryptographicException"/> and are counted by the caller.
    /// </summary>
    private async Task TryLoadAsync(string? passphrase)
    {
        if (_bundleTempPath is null || !File.Exists(_bundleTempPath))
        {
            _errorMessage = "No bundle staged — please re-select the file.";
            return;
        }
        try
        {
            // Read the staged bundle straight off disk; the
            // importer's LoadAsync only walks the stream forward, so a plain
            // FileStream is sufficient (no need to buffer it back into memory).
            using var stream = new FileStream(
                _bundleTempPath, FileMode.Open, FileAccess.Read, FileShare.Read,
                bufferSize: 81920, useAsync: true);
            _session = await BundleImporter.LoadAsync(stream, passphrase, CancellationToken.None);
            _errorMessage = null;
        }
        catch (ArgumentException)
        {
            // Encrypted bundle, no passphrase supplied — caller advances to Step 2.
            // We deliberately do NOT set _errorMessage here; the page surfaces
            // an empty Step-2 prompt instead.
            _session = null;
            throw;
        }
        catch (CryptographicException)
        {
            // Wrong passphrase — bubble so the caller can increment the counter.
            _session = null;
            throw;
        }
        catch (InvalidDataException ex)
        {
            _session = null;
            _errorMessage = $"Bundle is invalid: {ex.Message}";
        }
        catch (NotSupportedException ex)
        {
            _session = null;
            _errorMessage = $"Bundle format unsupported: {ex.Message}";
        }
        catch (InvalidOperationException ex)
        {
            _session = null;
            _errorMessage = ex.Message;
        }
    }

    /// <summary>
    /// Advances from Step 1 to either the passphrase step (encrypted bundle) or
    /// straight to the diff step (unencrypted bundle). For encrypted bundles
    /// LoadAsync was already attempted with <c>null</c> and threw
    /// <see cref="ArgumentException"/>, so <c>_session</c> is null and we move
    /// to Step 2. For unencrypted bundles <c>_session</c> is already populated;
    /// jump directly to Step 3.
    /// </summary>
    private async Task GoFromUploadAsync()
    {
        if (_session is null)
        {
            // Peek the manifest to find out if it's encrypted. We re-call LoadAsync
            // with null passphrase; for encrypted bundles this throws
            // ArgumentException → advance to Step 2.
            try
            {
                await TryLoadAsync(passphrase: null);
            }
            catch (ArgumentException)
            {
                _step = ImportWizardStep.Passphrase;
                return;
            }
            catch (CryptographicException)
            {
                _errorMessage = "Bundle could not be decrypted.";
                return;
            }
        }

        if (_session is null)
        {
            // Some other error already surfaced via _errorMessage.
            return;
        }
        if (_session.Manifest.Encryption is not null)
        {
            _step = ImportWizardStep.Passphrase;
        }
        else
        {
            await LoadPreviewAndAdvanceAsync();
        }
    }

    // ============================================================
    // Step 2 — Passphrase
    // ============================================================

    /// <summary>
    /// Submits the entered passphrase.
    /// <para>
    /// T-003: lockout enforcement is now server-side and keyed by the bundle's
    /// content hash. <see cref="CryptographicException"/> means "wrong passphrase,
    /// try again"; a <see cref="BundleLockedException"/> means the importer has
    /// observed enough failures against this bundle to lock it (the count is
    /// shared across tabs / CLI / circuits). The Razor counter is kept ONLY for
    /// display ("3 of N attempts used") — it is no longer the source of truth.
    /// </para>
    /// </summary>
    private async Task SubmitPassphraseAsync()
    {
        if (string.IsNullOrEmpty(_passphrase))
        {
            return;
        }
        _uploadInProgress = true;
        try
        {
            await TryLoadAsync(_passphrase);
            if (_session is not null)
            {
                _failedUnlockAttempts = 0;
                _passphrase = string.Empty;
                await LoadPreviewAndAdvanceAsync();
            }
        }
        catch (BundleLockedException ex)
        {
            // T-003: server-side lockout reached. Emit a final audit row so the
            // lockout is visible in the audit log, reset the wizard, and surface
            // the typed message verbatim.
            _passphrase = string.Empty;
            _failedUnlockAttempts = ex.FailedAttempts;
            await EmitUnlockFailedAuditRowAsync(ex.BundleContentHash, ex.FailedAttempts, ex.Message);
            _errorMessage = ex.Message;
            ResetSessionState();
            _step = ImportWizardStep.Upload;
        }
        catch (CryptographicException ex)
        {
            _failedUnlockAttempts++;
            _passphrase = string.Empty;

            var entityId = _session?.Manifest.ContentHash ?? "<no-session>";
            await EmitUnlockFailedAuditRowAsync(entityId, _failedUnlockAttempts, ex.Message);

            // The server tracks the authoritative counter; the local count is
            // kept in sync for the Razor display only.
            if (_failedUnlockAttempts >= Options.Value.MaxUnlockAttemptsPerSession)
            {
                _errorMessage =
                    $"Too many failed unlock attempts ({_failedUnlockAttempts}). "
                    + "Please re-upload the bundle.";
                ResetSessionState();
                _step = ImportWizardStep.Upload;
            }
            else
            {
                _errorMessage = "Wrong passphrase. Please try again.";
            }
        }
        catch (ArgumentException)
        {
            _errorMessage = "Passphrase required.";
        }
        finally
        {
            _uploadInProgress = false;
        }
    }

    /// <summary>
    /// T-003: best-effort audit row for a wrong-passphrase attempt. Audit failure
    /// must never abort the user-facing action — same defensive pattern as the
    /// original page used.
    /// </summary>
    private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
    {
        try
        {
            var user = await Auth.GetCurrentUsernameAsync();
            var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
            await AuditService.LogAsync(
                user: user,
                action: "BundleImportUnlockFailed",
                entityType: "Bundle",
                entityId: entityId,
                entityName: entityName,
                afterState: new
                {
                    AttemptNumber = attemptNumber,
                    Reason = reason,
                },
                cancellationToken: CancellationToken.None);
            await DbContext.SaveChangesAsync();
        }
        catch
        {
            // Audit failure is non-fatal — swallow and continue.
        }
    }

    private void BackToUpload()
    {
        // Reset all wizard + Map state so a subsequent re-upload starts from a
        // clean slate and cannot observe stale session / preview / name-map data
        // from the previous flow. ResetSessionState also clears _preview,
        // _resolutions, _siteChoices, _connectionChoices, etc.
        ResetSessionState();
        _step = ImportWizardStep.Upload;
        _errorMessage = null;
    }

    // ============================================================
    // Step 3 — Diff & resolve conflicts
    // ============================================================

    private async Task LoadPreviewAndAdvanceAsync()
    {
        if (_session is null) return;
        try
        {
            _preview = await BundleImporter.PreviewAsync(_session.SessionId, CancellationToken.None);
            _resolutions = BuildDefaultResolutions(_preview);
            await InitNameMappingStateAsync(_preview);
            _step = ImportWizardStep.Diff;
        }
        catch (Exception ex)
        {
            _errorMessage = $"Failed to build import preview: {ex.Message}";
        }
    }

    /// <summary>
    /// Prepares the Map sub-section state for a freshly loaded preview.
    /// When the preview carries no required site/connection mappings this is a
    /// no-op (the Map section is hidden) and we skip the site/connection reads
    /// entirely — central-config-only bundles never touch the destination's
    /// site catalogue.
    ///
    /// <para>Default choices follow the reference precedence: each site/connection
    /// is seeded to its <c>AutoMatch*</c> value when the importer found a match,
    /// otherwise to "Create new". The operator's explicit dropdown choice then
    /// wins over the auto-match default.</para>
    /// </summary>
    private async Task InitNameMappingStateAsync(ImportPreview preview)
    {
        _targetSites = Array.Empty<Site>();
        _targetConnections.Clear();
        _siteChoices.Clear();
        _connectionChoices.Clear();

        if (preview.RequiredSiteMappings.Count == 0 && preview.RequiredConnectionMappings.Count == 0)
        {
            return;
        }

        _targetSites = await SiteRepo.GetAllSitesAsync(CancellationToken.None);

        foreach (var rsm in preview.RequiredSiteMappings)
        {
            // Seed to the auto-matched target identifier when present AND that
            // target still exists in the destination; otherwise "Create new".
            var seed = rsm.AutoMatchTargetIdentifier is not null
                       && _targetSites.Any(s => s.SiteIdentifier == rsm.AutoMatchTargetIdentifier)
                ? rsm.AutoMatchTargetIdentifier
                : CreateNewValue;
            _siteChoices[rsm.SourceSiteIdentifier] = seed;

            // Eagerly load the seeded target's connections so the connection-row
            // dropdowns beneath this site can render their option sets without an
            // async hop on first render.
            if (!string.IsNullOrEmpty(seed) && !_targetConnections.ContainsKey(seed))
            {
                var target = _targetSites.First(s => s.SiteIdentifier == seed);
                _targetConnections[seed] =
                    await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
            }
        }

        foreach (var rcm in preview.RequiredConnectionMappings)
        {
            // Default the connection to its auto-match. We don't pre-validate it
            // against the chosen target's connection list here — the dropdown's
            // option set (driven by the chosen site) governs what's selectable,
            // and the seed simply renders as the initial selection.
            _connectionChoices[(rcm.SourceSiteIdentifier, rcm.SourceConnectionName)] =
                rcm.AutoMatchTargetName ?? CreateNewValue;
        }
    }

    /// <summary>
    /// Returns the connections of the destination site the operator chose for
    /// <paramref name="sourceSiteIdentifier"/> (used to populate a connection
    /// row's dropdown). Reads the <c>_targetConnections</c> cache, which is
    /// populated for every chosen target site by <see cref="InitNameMappingStateAsync"/>
    /// (seeded auto-matches) and <see cref="OnSiteChoiceChangedAsync"/> (operator
    /// changes). Returns an empty list when the source site maps to "Create new"
    /// (no existing target connections to bind to).
    /// </summary>
    private IReadOnlyList<DataConnection> ConnectionsForChosenTarget(string sourceSiteIdentifier)
    {
        if (!_siteChoices.TryGetValue(sourceSiteIdentifier, out var targetSiteIdentifier)
            || string.IsNullOrEmpty(targetSiteIdentifier))
        {
            return Array.Empty<DataConnection>();
        }
        return _targetConnections.TryGetValue(targetSiteIdentifier, out var cached)
            ? cached
            : Array.Empty<DataConnection>();
    }

    /// <summary>
    /// Handles a site-mapping dropdown change. Records the choice, eagerly loads +
    /// caches the new target's connections, then resets the per-connection choices
    /// for every <see cref="RequiredConnectionMapping"/> under this source site so
    /// that stale selections from the previous target cannot survive into
    /// <see cref="BuildNameMap"/>. Each connection is re-seeded: if the new target
    /// has a same-named connection the choice defaults to that name (matching the
    /// auto-match logic in <see cref="InitNameMappingStateAsync"/>); otherwise it
    /// falls back to the <see cref="CreateNewValue"/> sentinel ("Create new").
    /// </summary>
    private async Task OnSiteChoiceChangedAsync(string sourceSiteIdentifier, string? value)
    {
        var chosen = value ?? CreateNewValue;
        _siteChoices[sourceSiteIdentifier] = chosen;
        if (!string.IsNullOrEmpty(chosen) && !_targetConnections.ContainsKey(chosen))
        {
            var target = _targetSites.FirstOrDefault(s => s.SiteIdentifier == chosen);
            if (target is not null)
            {
                _targetConnections[chosen] =
                    await SiteRepo.GetDataConnectionsBySiteIdAsync(target.Id, CancellationToken.None);
            }
        }

        // Reset connection choices for every RequiredConnectionMapping belonging
        // to this source site. The previously-chosen connection name may not exist
        // on the newly-chosen target site, and leaving it would cause BuildNameMap
        // to emit a MapToExisting for a connection that does not exist there.
        if (_preview is not null)
        {
            foreach (var rcm in _preview.RequiredConnectionMappings
                .Where(m => m.SourceSiteIdentifier == sourceSiteIdentifier))
            {
                // Default to the new target's same-named connection when present;
                // otherwise fall back to Create-new.
                _connectionChoices[(sourceSiteIdentifier, rcm.SourceConnectionName)] =
                    (!string.IsNullOrEmpty(chosen)
                     && _targetConnections.TryGetValue(chosen, out var conns)
                     && conns.Any(c => c.Name == rcm.SourceConnectionName))
                        ? rcm.SourceConnectionName
                        : CreateNewValue;
            }
        }
    }

    private void OnConnectionChoiceChanged(string sourceSiteIdentifier, string sourceConnectionName, string? value)
    {
        _connectionChoices[(sourceSiteIdentifier, sourceConnectionName)] = value ?? CreateNewValue;
    }

    /// <summary>
    /// Folds the operator's Map-step choices into a <see cref="BundleNameMap"/>.
    /// A concrete chosen target → <see cref="MappingAction.MapToExisting"/> with that
    /// target identifier/name; the "Create new" sentinel → <see cref="MappingAction.CreateNew"/>
    /// with a null target. Iterates the preview's required mappings so the map's shape
    /// always matches what the bundle references (a missing choice falls back to its
    /// auto-match default, mirroring the seed). Returns <see cref="BundleNameMap.Empty"/>
    /// when the preview carries no required mappings.
    /// </summary>
    private BundleNameMap BuildNameMap()
    {
        if (_preview is null
            || (_preview.RequiredSiteMappings.Count == 0 && _preview.RequiredConnectionMappings.Count == 0))
        {
            return BundleNameMap.Empty;
        }

        var sites = new List<SiteMapping>(_preview.RequiredSiteMappings.Count);
        foreach (var rsm in _preview.RequiredSiteMappings)
        {
            var chosen = _siteChoices.TryGetValue(rsm.SourceSiteIdentifier, out var c)
                ? c
                : (rsm.AutoMatchTargetIdentifier ?? CreateNewValue);
            sites.Add(string.IsNullOrEmpty(chosen)
                ? new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.CreateNew, null)
                : new SiteMapping(rsm.SourceSiteIdentifier, MappingAction.MapToExisting, chosen));
        }

        var connections = new List<ConnectionMapping>(_preview.RequiredConnectionMappings.Count);
        foreach (var rcm in _preview.RequiredConnectionMappings)
        {
            var key = (rcm.SourceSiteIdentifier, rcm.SourceConnectionName);
            var chosen = _connectionChoices.TryGetValue(key, out var c)
                ? c
                : (rcm.AutoMatchTargetName ?? CreateNewValue);
            connections.Add(string.IsNullOrEmpty(chosen)
                ? new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.CreateNew, null)
                : new ConnectionMapping(rcm.SourceSiteIdentifier, rcm.SourceConnectionName, MappingAction.MapToExisting, chosen));
        }

        return new BundleNameMap(sites, connections);
    }

    /// <summary>
    /// Parses a Modified item's <c>FieldDiffJson</c> and returns the
    /// <c>lineDiff</c> object for the first code field that carries one, or null
    /// when the diff has no line-level payload (ordinary fields render as a coarse
    /// summary instead). Tolerant of malformed/absent JSON — a parse failure
    /// simply yields null so the row degrades to the coarse field-diff view.
    /// </summary>
    /// <param name="fieldDiffJson">The item's raw <c>FieldDiffJson</c> value, or null/blank.</param>
    /// <returns>The first <c>lineDiff</c> element found, or null if none is present.</returns>
    internal static JsonElement? TryExtractLineDiff(string? fieldDiffJson)
    {
        if (string.IsNullOrWhiteSpace(fieldDiffJson))
        {
            return null;
        }
        try
        {
            using var doc = JsonDocument.Parse(fieldDiffJson);
            if (!doc.RootElement.TryGetProperty("changes", out var changes)
                || changes.ValueKind != JsonValueKind.Array)
            {
                return null;
            }
            foreach (var change in changes.EnumerateArray())
            {
                if (change.ValueKind == JsonValueKind.Object
                    && change.TryGetProperty("lineDiff", out var lineDiff)
                    && lineDiff.ValueKind == JsonValueKind.Object)
                {
                    // Clone so the element outlives the JsonDocument we dispose.
                    return lineDiff.Clone();
                }
            }
            return null;
        }
        catch (JsonException)
        {
            return null;
        }
    }

    /// <summary>
    /// Builds the default resolution per preview item:
    /// <list type="bullet">
    ///   <item><see cref="ConflictKind.Identical"/> → <see cref="ResolutionAction.Skip"/></item>
    ///   <item><see cref="ConflictKind.New"/> → <see cref="ResolutionAction.Add"/></item>
    ///   <item><see cref="ConflictKind.Modified"/> → <see cref="ResolutionAction.Overwrite"/></item>
    ///   <item><see cref="ConflictKind.Blocker"/> → <see cref="ResolutionAction.Skip"/> (UI disables Apply anyway)</item>
    /// </list>
    /// Visible to tests via <c>internal</c> so the default-mapping contract is unit-pinned.
    /// </summary>
    /// <param name="preview">The import preview containing all conflict items to map.</param>
    /// <returns>A dictionary keyed by (EntityType, Name) with default resolution actions populated.</returns>
    internal static Dictionary<(string EntityType, string Name), ImportResolution> BuildDefaultResolutions(
        ImportPreview preview)
    {
        var map = new Dictionary<(string, string), ImportResolution>();
        foreach (var item in preview.Items)
        {
            var action = item.Kind switch
            {
                ConflictKind.Identical => ResolutionAction.Skip,
                ConflictKind.New => ResolutionAction.Add,
                ConflictKind.Modified => ResolutionAction.Overwrite,
                ConflictKind.Blocker => ResolutionAction.Skip,
                _ => ResolutionAction.Skip,
            };
            map[(item.EntityType, item.Name)] = new ImportResolution(
                item.EntityType, item.Name, action, RenameTo: null);
        }
        return map;
    }

    private void SetResolution((string EntityType, string Name) key, ResolutionAction action)
    {
        if (_resolutions is null) return;
        var existing = _resolutions[key];
        _resolutions[key] = existing with { Action = action };
    }

    private void SetRenameTo((string EntityType, string Name) key, string? renameTo)
    {
        if (_resolutions is null) return;
        var existing = _resolutions[key];
        _resolutions[key] = existing with { RenameTo = renameTo };
    }

    private void BulkSet(ResolutionAction action)
    {
        if (_resolutions is null || _preview is null) return;
        foreach (var item in _preview.Items)
        {
            if (item.Kind != ConflictKind.Modified) continue;
            var key = (item.EntityType, item.Name);
            _resolutions[key] = _resolutions[key] with { Action = action };
        }
    }

    private (int Adds, int Overs, int Skips, int Renames, int Blockers) CountResolutions()
    {
        if (_preview is null || _resolutions is null) return (0, 0, 0, 0, 0);
        var adds = 0;
        var overs = 0;
        var skips = 0;
        var renames = 0;
        var blockers = 0;
        foreach (var item in _preview.Items)
        {
            if (item.Kind == ConflictKind.Blocker)
            {
                blockers++;
                continue;
            }
            var action = _resolutions[(item.EntityType, item.Name)].Action;
            switch (action)
            {
                case ResolutionAction.Add: adds++; break;
                case ResolutionAction.Overwrite: overs++; break;
                case ResolutionAction.Skip: skips++; break;
                case ResolutionAction.Rename: renames++; break;
            }
        }
        return (adds, overs, skips, renames, blockers);
    }

    private void GoToConfirm()
    {
        if (_preview is null) return;
        if (_preview.Items.Any(i => i.Kind == ConflictKind.Blocker))
        {
            _errorMessage = "Cannot proceed while blockers exist — resolve or remove blocker rows first.";
            return;
        }
        _confirmEnvironmentText = string.Empty;
        _step = ImportWizardStep.Confirm;
    }

    private void BackToDiff()
    {
        _step = ImportWizardStep.Diff;
        _errorMessage = null;
        _validationErrors = null;
        _result = null;
    }

    // ============================================================
    // Step 4 + 5 — Confirm & Apply
    // ============================================================

    /// <summary>
    /// Invokes <see cref="IBundleImporter.ApplyAsync"/> with the collected
    /// resolutions and the authenticated user identity. Distinguishes
    /// <see cref="SemanticValidationException"/> (recoverable — surface the
    /// error list and let the operator return to Step 3) from generic
    /// exceptions (display generic error + force re-upload).
    /// </summary>
    private async Task ApplyAsync()
    {
        if (_session is null || _resolutions is null) return;
        if (_confirmEnvironmentText != _session.Manifest.SourceEnvironment) return;

        _applyInProgress = true;
        _errorMessage = null;
        _validationErrors = null;
        _result = null;
        try
        {
            var user = await Auth.GetCurrentUsernameAsync();
            // Fold the operator's Map-step choices into the name map. For
            // central-config-only bundles (no required mappings) this is
            // BundleNameMap.Empty, which the importer normalises away.
            var nameMap = BuildNameMap();
            _result = await BundleImporter.ApplyAsync(
                _session.SessionId,
                _resolutions.Values.ToList(),
                user,
                CancellationToken.None,
                nameMap: nameMap);
            _step = ImportWizardStep.Result;
        }
        catch (SemanticValidationException ex)
        {
            _validationErrors = ex.Errors;
            _step = ImportWizardStep.Result;
        }
        catch (Exception ex)
        {
            _errorMessage = $"Import failed: {ex.Message}. Please re-upload the bundle.";
            _step = ImportWizardStep.Result;
        }
        finally
        {
            _applyInProgress = false;
        }
    }

    // ============================================================
    // Reset helpers
    // ============================================================

    private void ResetSessionState()
    {
        _session = null;
        DeleteBundleTempFile();
        _preview = null;
        _resolutions = null;
        _passphrase = string.Empty;
        _confirmEnvironmentText = string.Empty;
        _result = null;
        _validationErrors = null;
        // Clear the Map sub-section state too.
        _targetSites = Array.Empty<Site>();
        _targetConnections.Clear();
        _siteChoices.Clear();
        _connectionChoices.Clear();
    }

    /// <summary>
    /// Deletes the staged bundle temp file if any. Swallows IO
    /// failures — an undeletable temp file is best-effort cleanup and must not
    /// block the wizard.
    /// </summary>
    private void DeleteBundleTempFile()
    {
        var path = _bundleTempPath;
        _bundleTempPath = null;
        if (path is null) return;
        try
        {
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }
        catch (IOException)
        {
            // Another handle may still be open (e.g. an in-flight LoadAsync
            // read). Leave the file behind; the OS temp dir is reaped on its
            // own schedule. Audit-failure-style: never block the user-facing
            // action.
        }
        catch (UnauthorizedAccessException)
        {
            // Same rationale.
        }
    }

    /// <summary>
    /// Ensures the staged temp file does not survive circuit
    /// teardown. Blazor invokes Dispose when the user navigates away or the
    /// circuit ends, so an abandoned wizard cleans up automatically.
    /// </summary>
    public void Dispose()
    {
        DeleteBundleTempFile();
    }
}
