using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
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.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;

namespace ZB.MOM.WW.ScadaBridge.Transport.IntegrationTests;

/// <summary>
/// T25 — full export→wipe→import round-trip through the real DI pipeline.
/// Confirms the bundle's content survives a complete cycle and that the
/// audit story (BundleExported on the way out, BundleImported on the way
/// in, plus per-entity stamping with BundleImportId) lines up.
/// <para>
/// Uses the same in-memory host pattern as <c>BundleExporterTests</c> and
/// <c>BundleImporterApplyTests</c> — real repositories, in-memory EF
/// provider, real Transport pipeline. The InMemory transaction warning is
/// downgraded for the same reason it is in <c>BundleImporterApplyTests</c>:
/// <c>ApplyAsync</c> opens a transaction that's a no-op here, and the
/// rollback-safety contract (single deferred SaveChangesAsync +
/// ChangeTracker.Clear on catch) keeps the test honest.
/// </para>
/// </summary>
public sealed class RoundTripTests : IDisposable
{
    private readonly ServiceProvider _provider;

    public RoundTripTests()
    {
        var services = new ServiceCollection();
        services.AddSingleton<IConfiguration>(
            new ConfigurationBuilder().AddInMemoryCollection().Build());

        // Same db across all scopes — see BundleExporterTests for the rationale.
        var dbName = $"RoundTripTests_{Guid.NewGuid()}";
        services.AddDbContext<ScadaBridgeDbContext>(opts => opts
            .UseInMemoryDatabase(dbName)
            .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)));

        // S10b: secret-bearing columns (SmsConfiguration.AuthToken, SmtpConfiguration
        // .Credentials, …) require the context's encrypting two-arg ctor with a Data
        // Protection key ring. Register an ephemeral provider and override the
        // AddDbContext registration to construct the encrypting context — mirrors the
        // production AddConfigurationDatabase wiring (and BundleImporterRollbackFailureTests).
        services.AddSingleton<IDataProtectionProvider>(new EphemeralDataProtectionProvider());
        services.AddScoped(sp => new ScadaBridgeDbContext(
            sp.GetRequiredService<DbContextOptions<ScadaBridgeDbContext>>(),
            sp.GetRequiredService<IDataProtectionProvider>()));

        services.AddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
        services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
        services.AddScoped<IExternalSystemRepository, ExternalSystemRepository>();
        services.AddScoped<INotificationRepository, NotificationRepository>();
        services.AddScoped<IInboundApiRepository, InboundApiRepository>();
        // M8: DependencyResolver now injects ISiteRepository to walk the
        // site/data-connection/instance closure; register it or activation fails.
        services.AddScoped<ISiteRepository, SiteRepository>();
        services.AddScoped<IAuditCorrelationContext, AuditCorrelationContext>();
        services.AddScoped<IAuditService, AuditService>();
        services.AddTransport();

        _provider = services.BuildServiceProvider();
    }

    public void Dispose() => _provider.Dispose();

    [Fact]
    public async Task Export_then_wipe_then_import_restores_state()
    {
        // ---- 1. Seed: 3 templates (one composing another + a standalone), 1
        //          SharedScript referenced by template script body, 1
        //          ExternalSystem referenced by template script body, 1
        //          NotificationList. ----
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            ctx.SharedScripts.Add(new SharedScript("HelperFn", "return 1;"));
            ctx.ExternalSystemDefinitions.Add(
                new ExternalSystemDefinition("ErpSystem", "https://erp.example", "ApiKey"));
            ctx.NotificationLists.Add(new NotificationList("OnCall"));
            await ctx.SaveChangesAsync();

            var baseTemplate = new Template("BaseDevice") { Description = "shared base" };
            // Script body references HelperFn + ErpSystem so the resolver pulls
            // them in via the substring scan. We deliberately keep the body
            // free of method-call tokens (e.g. `.Call(`) so the importer's
            // identifier-scan doesn't flag well-known SDK methods as missing
            // SharedScript blockers.
            baseTemplate.Scripts.Add(new TemplateScript("init",
                "var x = HelperFn(); var sys = ErpSystem;"));
            ctx.Templates.Add(baseTemplate);
            await ctx.SaveChangesAsync();

            var composing = new Template("Pump") { Description = "composes BaseDevice" };
            ctx.Templates.Add(composing);
            await ctx.SaveChangesAsync();
            composing.Compositions.Add(new TemplateComposition("base")
            {
                TemplateId = composing.Id,
                ComposedTemplateId = baseTemplate.Id,
            });

            ctx.Templates.Add(new Template("Standalone") { Description = "no deps" });
            await ctx.SaveChangesAsync();
        }

        // ---- 2. Export everything as a single encrypted bundle. ----
        byte[] bundleBytes;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            var templateIds = await ctx.Templates.Select(t => t.Id).ToListAsync();
            var sharedScriptIds = await ctx.SharedScripts.Select(s => s.Id).ToListAsync();
            var externalSystemIds = await ctx.ExternalSystemDefinitions.Select(x => x.Id).ToListAsync();
            var notificationListIds = await ctx.NotificationLists.Select(n => n.Id).ToListAsync();

            var selection = new ExportSelection(
                TemplateIds: templateIds,
                SharedScriptIds: sharedScriptIds,
                ExternalSystemIds: externalSystemIds,
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: notificationListIds,
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: Array.Empty<int>(),
                IncludeDependencies: true);

            var stream = await exporter.ExportAsync(
                selection, user: "alice", sourceEnvironment: "dev",
                passphrase: "secret", cancellationToken: CancellationToken.None);

            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            bundleBytes = ms.ToArray();
        }
        Assert.NotEmpty(bundleBytes);

        // Read manifest from the bundle so we can pin the ContentHash that
        // the BundleImported audit row should echo back inside its
        // AfterStateJson — that's the round-trip identity check.
        string manifestContentHash;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var serializer = scope.ServiceProvider.GetRequiredService<BundleSerializer>();
            using var ms = new MemoryStream(bundleBytes, writable: false);
            var manifest = serializer.ReadManifest(ms);
            manifestContentHash = manifest.ContentHash;
        }
        Assert.False(string.IsNullOrEmpty(manifestContentHash));

        // ---- 3. Wipe — delete every seeded entity AND the audit rows the
        //          export wrote so the post-import assertion can scan the
        //          whole table unambiguously. ----
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            ctx.TemplateCompositions.RemoveRange(ctx.TemplateCompositions);
            ctx.TemplateScripts.RemoveRange(ctx.TemplateScripts);
            ctx.Templates.RemoveRange(ctx.Templates);
            ctx.TemplateFolders.RemoveRange(ctx.TemplateFolders);
            ctx.SharedScripts.RemoveRange(ctx.SharedScripts);
            ctx.ExternalSystemDefinitions.RemoveRange(ctx.ExternalSystemDefinitions);
            ctx.NotificationRecipients.RemoveRange(ctx.NotificationRecipients);
            ctx.NotificationLists.RemoveRange(ctx.NotificationLists);
            ctx.AuditLogEntries.RemoveRange(ctx.AuditLogEntries);
            await ctx.SaveChangesAsync();
        }

        // ---- 4. Load the bundle → session. ----
        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            using var ms = new MemoryStream(bundleBytes, writable: false);
            var session = await importer.LoadAsync(ms, passphrase: "secret");
            sessionId = session.SessionId;
        }

        // ---- 5. Preview — every item must be ConflictKind.New now that the
        //          target is empty. ----
        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            preview = await importer.PreviewAsync(sessionId);
        }
        Assert.NotEmpty(preview.Items);
        Assert.All(preview.Items, item =>
            Assert.Equal(ConflictKind.New, item.Kind));

        // ---- 6. Build resolutions: every item gets Add. ----
        var resolutions = preview.Items
            .Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null))
            .ToList();

        // ---- 7. Apply. ----
        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
        }

        // ---- 8. Assertions ----
        await using (var assertScope = _provider.CreateAsyncScope())
        {
            var ctx = assertScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();

            // 8a. Original entities are all back. Re-query by name and spot-check
            //     the surviving key fields.
            var baseDevice = await ctx.Templates
                .Include(t => t.Scripts)
                .SingleAsync(t => t.Name == "BaseDevice");
            Assert.Equal("shared base", baseDevice.Description);
            Assert.Single(baseDevice.Scripts);
            Assert.Equal("init", baseDevice.Scripts.First().Name);

            var pump = await ctx.Templates
                .Include(t => t.Compositions)
                .SingleAsync(t => t.Name == "Pump");
            Assert.Equal("composes BaseDevice", pump.Description);
            // FU-B / #39 — composition graph IS restored on import. The bundle
            // carried Pump composing BaseDevice via InstanceName="base"; the
            // importer's second pass (ResolveCompositionEdgesAsync) re-resolved
            // ComposedTemplateName to BaseDevice's new id after the template
            // flush and re-added the row.
            var pumpComp = Assert.Single(pump.Compositions);
            Assert.Equal("base", pumpComp.InstanceName);
            Assert.Equal(baseDevice.Id, pumpComp.ComposedTemplateId);

            var standalone = await ctx.Templates.SingleAsync(t => t.Name == "Standalone");
            Assert.Equal("no deps", standalone.Description);

            var helper = await ctx.SharedScripts.SingleAsync(s => s.Name == "HelperFn");
            Assert.Equal("return 1;", helper.Code);

            var erp = await ctx.ExternalSystemDefinitions.SingleAsync(e => e.Name == "ErpSystem");
            Assert.Equal("https://erp.example", erp.EndpointUrl);

            var onCall = await ctx.NotificationLists.SingleAsync(n => n.Name == "OnCall");
            Assert.NotNull(onCall);

            // 8b. Exactly one BundleExported audit row (user=alice) survived the
            //     wipe? No — we deliberately wiped audit rows along with the
            //     entities. The export-side audit row was written BEFORE the
            //     wipe. Post-import there should therefore be zero
            //     BundleExported rows from before, plus none from the import
            //     (the importer doesn't emit BundleExported). The round-trip's
            //     export-side identity therefore lives ONLY in the bundle bytes
            //     we captured — manifestContentHash is the export's hash, and
            //     the BundleImported row's AfterStateJson echoes it back below.
            //     So this assertion targets the BundleImported side only.

            var imported = await ctx.AuditLogEntries
                .SingleAsync(a => a.Action == "BundleImported");
            Assert.Equal("bob", imported.User);
            Assert.Equal("Bundle", imported.EntityType);
            Assert.Equal(result.BundleImportId, imported.BundleImportId);
            Assert.NotNull(imported.AfterStateJson);
            // Round-trip identity: the import row carries the same ContentHash
            // we read from the bundle bytes above. That's the proof that the
            // export's bundle is exactly what the import consumed.
            Assert.Contains(manifestContentHash, imported.AfterStateJson!, StringComparison.Ordinal);

            // 8c. Every per-entity audit row written during the import run
            //     carries result.BundleImportId — that's the correlation
            //     contract from T5/T17 applied end-to-end here.
            var importRows = await ctx.AuditLogEntries
                .Where(a => a.BundleImportId == result.BundleImportId)
                .ToListAsync();
            Assert.NotEmpty(importRows);
            Assert.All(importRows, row =>
                Assert.Equal(result.BundleImportId, row.BundleImportId));
            // We seeded 3 templates + 1 shared script + 1 external system + 1
            // notification list = 6 created artifacts, plus the BundleImported
            // summary row = at least 7 stamped rows.
            Assert.True(importRows.Count >= 7,
                $"Expected at least 7 BundleImportId-stamped rows; got {importRows.Count}.");
        }

        // 8d. ImportResult counts line up with what we seeded.
        Assert.Equal(6, result.Added);
        Assert.Equal(0, result.Overwritten);
        Assert.Equal(0, result.Skipped);
        Assert.NotEqual(Guid.Empty, result.BundleImportId);
    }

    [Fact]
    public async Task Export_then_import_restores_sms_config_with_secret_intact()
    {
        // S10b: full export→wipe→import round-trip for an SMS provider config,
        // through the real pipeline (export selection → resolver → exporter →
        // importer apply). Confirms the config + its secret survive the cycle.

        // ---- 1. Seed one SMS config with an auth token. ----
        int smsId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var sms = new SmsConfiguration("AC_roundtrip_sid", "+15551239999")
            {
                AuthToken = "round-trip-token",
                MessagingServiceSid = "MG_rt",
                ApiBaseUrl = "https://api.example",
            };
            ctx.Set<SmsConfiguration>().Add(sms);
            await ctx.SaveChangesAsync();
            smsId = sms.Id;
        }

        // ---- 2. Export selecting only the SMS config. ----
        byte[] bundleBytes;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var exporter = scope.ServiceProvider.GetRequiredService<IBundleExporter>();
            var selection = new ExportSelection(
                TemplateIds: Array.Empty<int>(),
                SharedScriptIds: Array.Empty<int>(),
                ExternalSystemIds: Array.Empty<int>(),
                DatabaseConnectionIds: Array.Empty<int>(),
                NotificationListIds: Array.Empty<int>(),
                SmtpConfigurationIds: Array.Empty<int>(),
                ApiMethodIds: Array.Empty<int>(),
                IncludeDependencies: false,
                SmsConfigurationIds: new[] { smsId });

            var stream = await exporter.ExportAsync(
                selection, user: "alice", sourceEnvironment: "dev",
                passphrase: null, cancellationToken: CancellationToken.None);
            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            bundleBytes = ms.ToArray();
        }
        Assert.NotEmpty(bundleBytes);

        // ---- 3. Wipe the source config so the import exercises the Add path. ----
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            ctx.Set<SmsConfiguration>().RemoveRange(ctx.Set<SmsConfiguration>());
            await ctx.SaveChangesAsync();
        }

        // ---- 4. Load → preview → apply (every item gets Add). ----
        Guid sessionId;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            using var ms = new MemoryStream(bundleBytes, writable: false);
            var session = await importer.LoadAsync(ms, passphrase: null);
            sessionId = session.SessionId;
        }

        ImportPreview preview;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            preview = await importer.PreviewAsync(sessionId);
        }
        Assert.Contains(preview.Items,
            i => i.EntityType == "SmsConfiguration" && i.Name == "AC_roundtrip_sid" && i.Kind == ConflictKind.New);

        var resolutions = preview.Items
            .Select(it => new ImportResolution(it.EntityType, it.Name, ResolutionAction.Add, null))
            .ToList();

        ImportResult result;
        await using (var scope = _provider.CreateAsyncScope())
        {
            var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
            result = await importer.ApplyAsync(sessionId, resolutions, user: "bob");
        }

        // ---- 5. The config is back with its secret. ----
        await using (var scope = _provider.CreateAsyncScope())
        {
            var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            var restored = await ctx.Set<SmsConfiguration>()
                .SingleAsync(s => s.AccountSid == "AC_roundtrip_sid");
            Assert.Equal("+15551239999", restored.FromNumber);
            Assert.Equal("MG_rt", restored.MessagingServiceSid);
            Assert.Equal("round-trip-token", restored.AuthToken);
        }
        Assert.Equal(1, result.Added);
    }
}
