using System.Security.Cryptography;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.Extensions.Options;
using ScadaLink.CentralUI.Auth;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
using ScadaLink.ConfigurationDatabase;
using ScadaLink.Transport;
using ScadaLink.Transport.Import;
namespace ScadaLink.CentralUI.Components.Pages.Design;
///
/// Code-behind for the TransportImport wizard (Transport feature, Task T22).
///
/// Five-step state machine:
///
/// - — read bundle bytes, attempt
/// a passphrase-less ; if the
/// bundle is encrypted, advance to Step 2 without yet opening a session.
/// - — collect the passphrase
/// and retry LoadAsync; 3-strike lockout per the configured
/// .
/// - — render
/// items, collect per Modified item; Apply
/// is blocked while any remains.
/// - — type-the-environment-name
/// guard prevents accidental cross-cluster overwrites.
/// - — render Apply result + audit
/// drill-in link; on , surface
/// the error list and allow returning to Step 3.
///
///
/// The page is gated on RequireAdmin — Import touches central configuration
/// globally and must not be available to Design-only or Deployment-only users.
///
/// Cached bundle bytes: because currently
/// peeks the manifest by attempting decryption, encrypted bundles require two
/// LoadAsync invocations. We cache the raw bytes in _bundleBytes after the
/// first read so the user does not need to re-select the file before entering the
/// passphrase. The bytes are cleared on Done / Back-to-Upload.
///
public partial class TransportImport : ComponentBase
{
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 Options { get; set; } = default!;
[Inject] private IAuditService AuditService { get; set; } = default!;
[Inject] private ScadaLinkDbContext DbContext { get; set; } = default!;
// ---- Wizard state ----
private ImportWizardStep _step = ImportWizardStep.Upload;
private string? _errorMessage;
// ---- Session + cached bytes ----
// Bundle bytes are cached so the same file can be re-attempted with a
// passphrase without forcing the user to re-pick it. Cleared in ResetAll.
private byte[]? _bundleBytes;
private BundleSession? _session;
private bool _uploadInProgress;
// ---- 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 4: confirm ----
private string _confirmEnvironmentText = string.Empty;
// ---- Step 5: apply result ----
private bool _applyInProgress;
private ImportResult? _result;
private IReadOnlyList? _validationErrors;
// ============================================================
// Step 1 — Upload
// ============================================================
///
/// Buffers the selected file, enforces the configured size cap, then calls
/// with no passphrase to peek the
/// manifest. Encrypted bundles surface as ,
/// which we catch and use to advance to Step 2 — the session is opened on
/// the second LoadAsync call once the passphrase is provided.
///
private async Task OnFileSelectedAsync(InputFileChangeEventArgs e)
{
_errorMessage = null;
_uploadInProgress = true;
_session = null;
_bundleBytes = null;
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;
}
// 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.
using var fileStream = e.File.OpenReadStream(maxBytes);
using var ms = new MemoryStream();
await fileStream.CopyToAsync(ms);
_bundleBytes = ms.ToArray();
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;
}
}
///
/// Attempts to open a from the cached bytes with
/// the given passphrase. On (encrypted bundle
/// with no passphrase) leaves the wizard's step caller to advance to the
/// passphrase step. Wrong-passphrase failures surface as
/// and are counted by the caller.
///
private async Task TryLoadAsync(string? passphrase)
{
if (_bundleBytes is null)
{
_errorMessage = "No bundle bytes cached — please re-select the file.";
return;
}
try
{
using var stream = new MemoryStream(_bundleBytes);
_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;
}
}
///
/// 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 null and threw
/// , so _session is null and we move
/// to Step 2. For unencrypted bundles _session is already populated;
/// jump directly to Step 3.
///
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
// ============================================================
///
/// Submits the entered passphrase.
///
/// T-003: lockout enforcement is now server-side and keyed by the bundle's
/// content hash. means "wrong passphrase,
/// try again"; a 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.
///
///
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 ?? "";
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;
}
}
///
/// 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.
///
private async Task EmitUnlockFailedAuditRowAsync(string entityId, int attemptNumber, string reason)
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var entityName = _session?.Manifest.SourceEnvironment ?? "";
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()
{
_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);
_step = ImportWizardStep.Diff;
}
catch (Exception ex)
{
_errorMessage = $"Failed to build import preview: {ex.Message}";
}
}
///
/// Builds the default resolution per preview item:
///
/// - →
/// - →
/// - →
/// - → (UI disables Apply anyway)
///
/// Visible to tests via internal so the default-mapping contract is unit-pinned.
///
/// The import preview containing all conflict items to map.
/// A dictionary keyed by (EntityType, Name) with default resolution actions populated.
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
// ============================================================
///
/// Invokes with the collected
/// resolutions and the authenticated user identity. Distinguishes
/// (recoverable — surface the
/// error list and let the operator return to Step 3) from generic
/// exceptions (display generic error + force re-upload).
///
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();
_result = await BundleImporter.ApplyAsync(
_session.SessionId,
_resolutions.Values.ToList(),
user,
CancellationToken.None);
_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;
_bundleBytes = null;
_preview = null;
_resolutions = null;
_passphrase = string.Empty;
_confirmEnvironmentText = string.Empty;
_result = null;
_validationErrors = null;
}
}