using System.Security.Claims;
using System.Security.Cryptography;
using Bunit;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
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.Security;
using ZB.MOM.WW.ScadaBridge.Transport;
using TransportImportPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Design.TransportImport;

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

/// <summary>
/// bUnit + logic tests for the TransportImport wizard (Component #24, Task T22).
///
/// <para>
/// The wizard has five steps (Upload / Passphrase / Diff / Confirm / Result).
/// Selecting a file via <c>InputFile</c> is hard to drive cleanly from bUnit
/// (JS interop + DotNetStreamReference), so the state-machine tests reach into
/// the page instance via <c>cut.Instance</c> and the <c>InternalsVisibleTo</c>
/// declaration on <c>ZB.MOM.WW.ScadaBridge.CentralUI.csproj</c>. The <c>BundleImporter</c>
/// mock controls every load/preview/apply contract so each step's behaviour can
/// be exercised in isolation. The full happy-path round-trip is covered by the
/// integration tests in <c>ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests</c>.
/// </para>
/// </summary>
public class TransportImportPageTests : BunitContext
{
    private readonly IBundleImporter _importer = Substitute.For<IBundleImporter>();
    private readonly IAuditService _auditService = Substitute.For<IAuditService>();

    public TransportImportPageTests()
    {
        JSInterop.Mode = JSRuntimeMode.Loose;

        Services.AddSingleton(_importer);
        Services.AddSingleton(_auditService);
        Services.AddSingleton<IOptions<TransportOptions>>(
            Microsoft.Extensions.Options.Options.Create(new TransportOptions
            {
                MaxBundleSizeMb = 10,
                MaxUnlockAttemptsPerSession = 3,
            }));

        // Provide a SQLite in-memory ScadaBridgeDbContext so the page's
        // DbContext.SaveChangesAsync() calls in the audit path succeed.
        var dbOptions = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlite("DataSource=:memory:")
            .ConfigureWarnings(w => w.Ignore(RelationalEventId.AmbientTransactionWarning))
            .Options;
        var dbContext = new ScadaBridgeDbContext(dbOptions);
        dbContext.Database.OpenConnection();
        dbContext.Database.EnsureCreated();
        Services.AddSingleton(dbContext);

        var principal = BuildPrincipal("alice", "Administrator");
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(principal));
        Services.AddAuthorizationCore();
    }

    private static ClaimsPrincipal BuildPrincipal(string username, params string[] roles)
    {
        var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, username) };
        claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
        return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
    }

    private static BundleSession BuildEncryptedSession(string sourceEnv = "prod-cluster") =>
        new()
        {
            SessionId = Guid.NewGuid(),
            Manifest = new BundleManifest(
                BundleFormatVersion: 1,
                SchemaVersion: "1.0",
                CreatedAtUtc: DateTimeOffset.UtcNow,
                SourceEnvironment: sourceEnv,
                ExportedBy: "bob",
                ScadaBridgeVersion: "1.0.0",
                ContentHash: "sha256:0000",
                Encryption: new EncryptionMetadata(
                    Algorithm: "AES-256-GCM",
                    Kdf: "PBKDF2-SHA256",
                    Iterations: 600_000,
                    SaltB64: "abc",
                    IvB64: "def"),
                Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
                Contents: Array.Empty<ManifestContentEntry>()),
            DecryptedContent = Array.Empty<byte>(),
            ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
        };

    // ─────────────────────────────────────────────────────────────────────
    // Test 1: Step 1 renders the InputFile upload control.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void Renders_step1_upload_input()
    {
        var cut = Render<TransportImportPage>();
        // Bootstrap classes are applied by InputFile via the CSS class attribute.
        Assert.NotNull(cut.Find("input[type='file']"));
        // The Bootstrap step indicator should highlight Step 1.
        Assert.Contains("Upload", cut.Markup);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 2: Wrong passphrase increments the failure counter without
    // advancing past Step 2.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Decryption_failure_increments_attempt_counter()
    {
        // Set up the importer to throw CryptographicException for wrong passphrases.
        _importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new CryptographicException("authentication tag mismatch"));

        var cut = Render<TransportImportPage>();
        await cut.InvokeAsync(() =>
        {
            // Seed the wizard at the passphrase step with cached bytes.
            SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
            SetField(cut.Instance, "_passphrase", "wrong-pass");
        });

        // Drive a passphrase submission.
        await cut.InvokeAsync(async () =>
        {
            await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
        });

        Assert.Equal(1, GetField<int>(cut.Instance, "_failedUnlockAttempts"));
        Assert.Equal(
            TransportImportPage.ImportWizardStep.Passphrase,
            GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 3: After MaxUnlockAttemptsPerSession failures the wizard returns
    // to Step 1 with an explanatory error.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Three_failed_unlocks_force_reupload()
    {
        _importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new CryptographicException("authentication tag mismatch"));

        var cut = Render<TransportImportPage>();
        await cut.InvokeAsync(() =>
        {
            SeedAtPassphraseStep(cut.Instance, new byte[] { 0x01, 0x02 });
        });

        for (var i = 0; i < 3; i++)
        {
            await cut.InvokeAsync(async () =>
            {
                SetField(cut.Instance, "_passphrase", $"wrong-{i}");
                await InvokeAsyncMethod(cut.Instance, "SubmitPassphraseAsync");
            });
        }

        Assert.Equal(
            TransportImportPage.ImportWizardStep.Upload,
            GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
        var errorMessage = GetField<string?>(cut.Instance, "_errorMessage");
        Assert.NotNull(errorMessage);
        Assert.Contains("Too many failed unlock attempts", errorMessage);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 4: Confirm step requires an exact match (case-sensitive) on the
    // source environment name before Apply is enabled.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Confirm_step_requires_exact_environment_name_match()
    {
        var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
        _importer.PreviewAsync(session.SessionId, Arg.Any<CancellationToken>())
            .Returns(new ImportPreview(session.SessionId, new List<ImportPreviewItem>
            {
                new("Template", "Pump", null, 1, ConflictKind.New, null, null),
            }));

        var cut = Render<TransportImportPage>();
        await cut.InvokeAsync(() =>
        {
            SetField(cut.Instance, "_session", session);
            SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
            {
                new("Template", "Pump", null, 1, ConflictKind.New, null, null),
            }));
            SetField(cut.Instance, "_resolutions", new Dictionary<(string EntityType, string Name), ImportResolution>
            {
                [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Add, null),
            });
            SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
        });

        // Wrong text → Apply button is disabled.
        await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "wrong"));
        cut.Render();
        var applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
        Assert.True(applyBtn.HasAttribute("disabled"));

        // Case mismatch → still disabled.
        await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "PROD-CLUSTER"));
        cut.Render();
        applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
        Assert.True(applyBtn.HasAttribute("disabled"));

        // Exact match → enabled.
        await cut.InvokeAsync(() => SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster"));
        cut.Render();
        applyBtn = cut.FindAll("button").First(b => b.TextContent.Trim().StartsWith("Apply Import"));
        Assert.False(applyBtn.HasAttribute("disabled"));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 5: ApplyAsync is invoked with the chosen resolutions and the
    // authenticated user identity.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Apply_step_invokes_BundleImporter_ApplyAsync_with_resolutions()
    {
        var session = BuildEncryptedSession(sourceEnv: "prod-cluster");
        var resolutions = new Dictionary<(string EntityType, string Name), ImportResolution>
        {
            [("Template", "Pump")] = new("Template", "Pump", ResolutionAction.Overwrite, null),
        };
        var expectedResult = new ImportResult(
            BundleImportId: Guid.NewGuid(),
            Added: 0,
            Overwritten: 1,
            Skipped: 0,
            Renamed: 0,
            StaleInstanceIds: Array.Empty<int>(),
            AuditEventCorrelation: Guid.NewGuid().ToString());

        _importer.ApplyAsync(
                session.SessionId,
                Arg.Any<IReadOnlyList<ImportResolution>>(),
                "alice",
                Arg.Any<CancellationToken>())
            .Returns(expectedResult);

        var cut = Render<TransportImportPage>();
        await cut.InvokeAsync(() =>
        {
            SetField(cut.Instance, "_session", session);
            SetField(cut.Instance, "_preview", new ImportPreview(session.SessionId, new List<ImportPreviewItem>
            {
                new("Template", "Pump", 1, 2, ConflictKind.Modified, null, null),
            }));
            SetField(cut.Instance, "_resolutions", resolutions);
            SetField(cut.Instance, "_step", TransportImportPage.ImportWizardStep.Confirm);
            SetField(cut.Instance, "_confirmEnvironmentText", "prod-cluster");
        });

        await cut.InvokeAsync(async () =>
        {
            await InvokeAsyncMethod(cut.Instance, "ApplyAsync");
        });

        await _importer.Received(1).ApplyAsync(
            session.SessionId,
            Arg.Is<IReadOnlyList<ImportResolution>>(rs =>
                rs.Any(r => r.EntityType == "Template" && r.Name == "Pump"
                    && r.Action == ResolutionAction.Overwrite)),
            "alice",
            Arg.Any<CancellationToken>());

        Assert.Equal(
            TransportImportPage.ImportWizardStep.Result,
            GetField<TransportImportPage.ImportWizardStep>(cut.Instance, "_step"));
        Assert.Equal(expectedResult, GetField<ImportResult?>(cut.Instance, "_result"));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 6: A user without the Admin role fails the RequireAdmin policy.
    // The router enforces [Authorize(Policy=...)] at request time — bUnit
    // doesn't model routing, so we verify the policy itself denies the
    // principal.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public async Task Page_returns_unauthorized_for_user_without_Admin_role()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddScadaBridgeAuthorization();
        using var provider = services.BuildServiceProvider();
        var authService = provider.GetRequiredService<IAuthorizationService>();

        // Design-only user — has a role but it isn't Admin.
        var principal = BuildPrincipal("bob", "Designer");
        var result = await authService.AuthorizeAsync(
            principal, null, AuthorizationPolicies.RequireAdmin);

        Assert.False(result.Succeeded);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Test 7 (helper coverage): BuildDefaultResolutions maps each kind to the
    // expected default ResolutionAction.
    // ─────────────────────────────────────────────────────────────────────
    [Fact]
    public void BuildDefaultResolutions_maps_kinds_to_actions()
    {
        var preview = new ImportPreview(Guid.NewGuid(), new List<ImportPreviewItem>
        {
            new("Template", "A", 1, 1, ConflictKind.Identical, null, null),
            new("Template", "B", null, 1, ConflictKind.New, null, null),
            new("Template", "C", 1, 2, ConflictKind.Modified, null, null),
            new("Reference", "D", null, null, ConflictKind.Blocker, null, "missing dep"),
        });

        var map = TransportImportPage.BuildDefaultResolutions(preview);

        Assert.Equal(ResolutionAction.Skip, map[("Template", "A")].Action);
        Assert.Equal(ResolutionAction.Add, map[("Template", "B")].Action);
        Assert.Equal(ResolutionAction.Overwrite, map[("Template", "C")].Action);
        Assert.Equal(ResolutionAction.Skip, map[("Reference", "D")].Action);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Reflection helpers — the wizard's per-instance state is private (the
    // razor partial pattern). We poke at it via reflection rather than
    // widening the surface of the production class with test-only accessors.
    // ─────────────────────────────────────────────────────────────────────

    private static void SetField(object obj, string name, object? value)
    {
        var field = obj.GetType().GetField(
            name,
            System.Reflection.BindingFlags.Instance
            | System.Reflection.BindingFlags.NonPublic
            | System.Reflection.BindingFlags.Public)
            ?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
        field.SetValue(obj, value);
    }

    private static T GetField<T>(object obj, string name)
    {
        var field = obj.GetType().GetField(
            name,
            System.Reflection.BindingFlags.Instance
            | System.Reflection.BindingFlags.NonPublic
            | System.Reflection.BindingFlags.Public)
            ?? throw new InvalidOperationException($"Field '{name}' not found on {obj.GetType()}.");
        return (T)field.GetValue(obj)!;
    }

    private static async Task InvokeAsyncMethod(object obj, string name)
    {
        var method = obj.GetType().GetMethod(
            name,
            System.Reflection.BindingFlags.Instance
            | System.Reflection.BindingFlags.NonPublic
            | System.Reflection.BindingFlags.Public)
            ?? throw new InvalidOperationException($"Method '{name}' not found on {obj.GetType()}.");
        var task = (Task)method.Invoke(obj, Array.Empty<object?>())!;
        await task;
    }

    /// <summary>
    /// Seeds the wizard at Step 2 (Passphrase) with a staged bundle file — the
    /// shape after an encrypted-bundle upload completed Step 1's peek and
    /// surfaced an ArgumentException ("passphrase required"). CentralUI-031:
    /// the wizard now stages the upload to a temp file and only retains the
    /// path on the component, so the test helper writes the bytes to a per-
    /// test temp file and sets the path field instead of the byte[] field.
    /// </summary>
    private static void SeedAtPassphraseStep(TransportImportPage instance, byte[] bytes)
    {
        var dir = Path.Combine(Path.GetTempPath(), "scadabridge-transport-staging");
        Directory.CreateDirectory(dir);
        var path = Path.Combine(dir, $"test-{Guid.NewGuid():N}.scadabundle");
        File.WriteAllBytes(path, bytes);
        SetField(instance, "_bundleTempPath", path);
        SetField(instance, "_session", null);
        SetField(instance, "_step", TransportImportPage.ImportWizardStep.Passphrase);
        SetField(instance, "_failedUnlockAttempts", 0);
    }
}
