using System.IO.Compression;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
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.TemplateEngine.Validation;
using ZB.MOM.WW.ScadaBridge.Transport.Encryption;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;

namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import;

/// <summary>
/// Unit tests for <see cref="BundleImporter.LoadAsync"/>. Uses the real
/// <see cref="BundleSerializer"/>, <see cref="ManifestValidator"/>,
/// <see cref="BundleSecretEncryptor"/>, <see cref="EntitySerializer"/> and
/// <see cref="BundleSessionStore"/> — they're stateless / in-memory and easier
/// to drive than mocks. Repositories + audit + DbContext are mocked because
/// LoadAsync does not exercise them (they're injected so the constructor stays
/// stable across T15/T16/T17).
/// </summary>
public sealed class BundleImporterLoadTests
{
    private static readonly JsonSerializerOptions BundleJsonOptions = new()
    {
        WriteIndented = false,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Converters = { new JsonStringEnumConverter() },
    };

    private static BundleContentDto EmptyContent() => new(
        TemplateFolders: Array.Empty<TemplateFolderDto>(),
        Templates: Array.Empty<TemplateDto>(),
        SharedScripts: Array.Empty<SharedScriptDto>(),
        ExternalSystems: Array.Empty<ExternalSystemDto>(),
        DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
        NotificationLists: Array.Empty<NotificationListDto>(),
        SmtpConfigs: Array.Empty<SmtpConfigDto>(),
        ApiMethods: Array.Empty<ApiMethodDto>());

    private static BundleContentDto SmallContent() => new(
        TemplateFolders: Array.Empty<TemplateFolderDto>(),
        Templates: new[]
        {
            new TemplateDto(
                Name: "Pump",
                FolderName: null,
                BaseTemplateName: null,
                Description: "the one and only",
                Attributes: Array.Empty<TemplateAttributeDto>(),
                Alarms: Array.Empty<TemplateAlarmDto>(),
                Scripts: Array.Empty<TemplateScriptDto>(),
                Compositions: Array.Empty<TemplateCompositionDto>()),
        },
        SharedScripts: Array.Empty<SharedScriptDto>(),
        ExternalSystems: Array.Empty<ExternalSystemDto>(),
        DatabaseConnections: Array.Empty<DatabaseConnectionDto>(),
        NotificationLists: Array.Empty<NotificationListDto>(),
        SmtpConfigs: Array.Empty<SmtpConfigDto>(),
        ApiMethods: Array.Empty<ApiMethodDto>());

    private sealed class TestTimeProvider : TimeProvider
    {
        private DateTimeOffset _now;
        public TestTimeProvider(DateTimeOffset start) { _now = start; }
        public override DateTimeOffset GetUtcNow() => _now;
    }

    private sealed record TestRig(
        BundleImporter Importer,
        BundleSerializer Serializer,
        ManifestBuilder ManifestBuilder,
        BundleSecretEncryptor Encryptor,
        BundleSessionStore SessionStore,
        TransportOptions Options);

    private static TestRig BuildRig(Action<TransportOptions>? configure = null)
    {
        var opts = new TransportOptions();
        configure?.Invoke(opts);
        var iOpts = Options.Create(opts);
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var serializer = new BundleSerializer();
        var validator = new ManifestValidator();
        var encryptor = new BundleSecretEncryptor();
        var entitySerializer = new EntitySerializer();
        var manifestBuilder = new ManifestBuilder();
        var store = new BundleSessionStore(iOpts, clock);

        var importer = new BundleImporter(
            bundleSerializer: serializer,
            manifestValidator: validator,
            encryptor: encryptor,
            entitySerializer: entitySerializer,
            sessionStore: store,
            // T-004: the unlock rate limiter shares the test clock so its trailing-hour
            // window pruning is deterministic. The window itself is the production
            // default (1 hour).
            unlockRateLimiter: new BundleUnlockRateLimiter(clock, BundleUnlockRateLimiter.DefaultWindow),
            options: iOpts,
            timeProvider: clock,
            templateRepo: Substitute.For<ITemplateEngineRepository>(),
            externalRepo: Substitute.For<IExternalSystemRepository>(),
            notificationRepo: Substitute.For<INotificationRepository>(),
            inboundApiRepo: Substitute.For<IInboundApiRepository>(),
            siteRepo: Substitute.For<ISiteRepository>(),
            auditService: Substitute.For<IAuditService>(),
            correlationContext: Substitute.For<IAuditCorrelationContext>(),
            // LoadAsync never touches the DbContext — Preview/Apply do. Build
            // a no-provider DbContext so the importer's null check passes;
            // the in-memory provider isn't worth pulling in for unit tests.
            dbContext: new ScadaBridgeDbContext(
                new DbContextOptionsBuilder<ScadaBridgeDbContext>().Options),
            semanticValidator: new SemanticValidator());

        return new TestRig(importer, serializer, manifestBuilder, encryptor, store, opts);
    }

    private static Stream PackPlainBundle(BundleSerializer serializer, ManifestBuilder builder, BundleContentDto content)
    {
        var contentBytes = serializer.SerializeContentBytes(content);
        var manifest = builder.Build(
            sourceEnvironment: "dev",
            exportedBy: "alice",
            scadaBridgeVersion: "1.0.0",
            encryption: null,
            summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
            contents: Array.Empty<ManifestContentEntry>(),
            contentBytes: contentBytes);
        return serializer.Pack(content, manifest, passphrase: null, encryptor: null);
    }

    private static Stream PackEncryptedBundle(
        BundleSerializer serializer,
        ManifestBuilder builder,
        BundleSecretEncryptor encryptor,
        BundleContentDto content,
        string passphrase)
    {
        var contentBytes = serializer.SerializeContentBytes(content);
        // Pack re-stamps salt/iv/hash from the ciphertext it actually writes,
        // so the seed values here are placeholders.
        var seed = new EncryptionMetadata("AES-256-GCM", "PBKDF2-SHA256", 600_000, string.Empty, string.Empty);
        var manifest = builder.Build(
            sourceEnvironment: "dev",
            exportedBy: "alice",
            scadaBridgeVersion: "1.0.0",
            encryption: seed,
            summary: new BundleSummary(content.Templates.Count, 0, 0, 0, 0, 0, 0, 0),
            contents: Array.Empty<ManifestContentEntry>(),
            contentBytes: contentBytes);
        return serializer.Pack(content, manifest, passphrase, encryptor);
    }

    [Fact]
    public async Task LoadAsync_returns_session_for_unencrypted_bundle()
    {
        var rig = BuildRig();
        var content = SmallContent();
        var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, content);

        var session = await rig.Importer.LoadAsync(stream, passphrase: null);

        Assert.NotNull(session);
        Assert.NotEqual(Guid.Empty, session.SessionId);
        Assert.Equal("dev", session.Manifest.SourceEnvironment);
        Assert.Equal("alice", session.Manifest.ExportedBy);
        Assert.Null(session.Manifest.Encryption);
        Assert.NotNull(rig.SessionStore.Get(session.SessionId));
    }

    [Fact]
    public async Task LoadAsync_returns_session_for_encrypted_bundle_with_correct_passphrase()
    {
        var rig = BuildRig();
        var content = SmallContent();
        var stream = PackEncryptedBundle(rig.Serializer, rig.ManifestBuilder, rig.Encryptor, content, "secret123");

        var session = await rig.Importer.LoadAsync(stream, passphrase: "secret123");

        Assert.NotNull(session);
        Assert.NotNull(session.Manifest.Encryption);
        Assert.NotEmpty(session.DecryptedContent);
        // The decrypted payload must round-trip back to the original DTO so the
        // PreviewAsync phase can deserialize it directly from the session.
        var roundTripped = JsonSerializer.Deserialize<BundleContentDto>(
            session.DecryptedContent, BundleJsonOptions);
        Assert.NotNull(roundTripped);
        Assert.Single(roundTripped!.Templates);
        Assert.Equal("Pump", roundTripped.Templates[0].Name);
    }

    [Fact]
    public async Task LoadAsync_throws_when_passphrase_wrong()
    {
        var rig = BuildRig();
        var stream = PackEncryptedBundle(
            rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");

        // AES-GCM raises AuthenticationTagMismatchException, a CryptographicException
        // subclass on .NET 10 — ThrowsAny is the right match.
        await Assert.ThrowsAnyAsync<CryptographicException>(
            () => rig.Importer.LoadAsync(stream, passphrase: "wrong"));
    }

    [Fact]
    public async Task LoadAsync_locks_bundle_after_three_wrong_passphrases_even_across_callers()
    {
        // T-003: the lockout is server-side and keyed by ContentHash, so replaying
        // the SAME bundle bytes from a second caller (different stream, different
        // session) must hit the same counter. After MaxUnlockAttemptsPerSession (3)
        // failures the importer throws BundleLockedException, not another
        // CryptographicException — and the lock survives a fresh LoadAsync from a
        // pristine caller that has no idea about the prior attempts.
        var rig = BuildRig();
        using var packed = PackEncryptedBundle(
            rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
        var bundleBytes = ((MemoryStream)packed).ToArray();

        // First two wrong-passphrase attempts surface as CryptographicException.
        for (var attempt = 1; attempt <= 2; attempt++)
        {
            await Assert.ThrowsAnyAsync<CryptographicException>(
                () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
        }

        // Third wrong-passphrase attempt crosses the threshold and surfaces as
        // BundleLockedException — even though this is a fresh stream / a caller
        // that never saw the earlier failures.
        var locked = await Assert.ThrowsAsync<BundleLockedException>(
            () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
        Assert.Equal(3, locked.FailedAttempts);

        // Fourth attempt — even with the CORRECT passphrase — is still locked,
        // because the lockout sticks until the TTL expires or the bundle is
        // re-exported with a new content hash.
        await Assert.ThrowsAsync<BundleLockedException>(
            () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct"));
    }

    [Fact]
    public async Task LoadAsync_rejects_bundle_with_too_many_entries()
    {
        // T-006: a malicious bundle could pad the archive with arbitrary entries to
        // exhaust per-entry handles or to slip an unexpected payload past the
        // serializer. The envelope check rejects any archive whose entry count
        // exceeds MaxBundleEntryCount BEFORE any decompression.
        var rig = BuildRig(opts => opts.MaxBundleEntryCount = 2);
        using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
        var bytes = ((MemoryStream)packed).ToArray();
        var paddedBytes = AppendExtraZipEntry(bytes, "extra.bin", new byte[8]);

        await Assert.ThrowsAsync<InvalidDataException>(
            () => rig.Importer.LoadAsync(new MemoryStream(paddedBytes), passphrase: null));
    }

    [Fact]
    public async Task LoadAsync_rejects_bundle_with_oversized_entry()
    {
        // T-006: caller-declared decompressed Length above the configured cap is
        // a hostile bundle; reject without decompressing.
        var rig = BuildRig(opts => opts.MaxBundleEntryDecompressedMb = 1);
        using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
        var bytes = ((MemoryStream)packed).ToArray();
        // Replace content.json with a 2 MB entry of compressible zeros — uncompressed Length > cap.
        var bigPayload = new byte[2 * 1024 * 1024];
        var oversizedBytes = ReplaceZipEntry(bytes, "content.json", bigPayload);

        await Assert.ThrowsAsync<InvalidDataException>(
            () => rig.Importer.LoadAsync(new MemoryStream(oversizedBytes), passphrase: null));
    }

    [Fact]
    public async Task LoadAsync_rejects_bundle_whose_entry_exceeds_compression_ratio()
    {
        // T-006: defence-in-depth — even if Length is within the per-entry cap, an
        // extreme compression ratio is a hallmark of a decompression bomb and is
        // rejected outright.
        var rig = BuildRig(opts =>
        {
            opts.MaxBundleEntryDecompressedMb = 100;
            opts.MaxBundleEntryCompressionRatio = 10;
        });
        using var packed = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, EmptyContent());
        var bytes = ((MemoryStream)packed).ToArray();
        // 1 MB of zeros compresses extremely well (>100x ratio) — well over the
        // configured 10x cap.
        var compressible = new byte[1024 * 1024];
        var bombBytes = ReplaceZipEntry(bytes, "content.json", compressible);

        await Assert.ThrowsAsync<InvalidDataException>(
            () => rig.Importer.LoadAsync(new MemoryStream(bombBytes), passphrase: null));
    }

    /// <summary>
    /// T-006 helper: rewrites an existing zip to add a fresh entry alongside the
    /// originals. Used by the "too many entries" test.
    /// </summary>
    private static byte[] AppendExtraZipEntry(byte[] zipBytes, string newEntryName, byte[] payload)
    {
        var output = new MemoryStream();
        using (var src = new MemoryStream(zipBytes))
        using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
        using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
        {
            foreach (var entry in srcZip.Entries)
            {
                var dst = dstZip.CreateEntry(entry.FullName);
                using var inStream = entry.Open();
                using var outStream = dst.Open();
                inStream.CopyTo(outStream);
            }
            var extra = dstZip.CreateEntry(newEntryName);
            using (var s = extra.Open()) { s.Write(payload); }
        }
        return output.ToArray();
    }

    /// <summary>
    /// T-006 helper: rewrites an existing zip, replacing one entry's bytes with
    /// the supplied payload while preserving every other entry verbatim.
    /// </summary>
    private static byte[] ReplaceZipEntry(byte[] zipBytes, string entryToReplace, byte[] newPayload)
    {
        var output = new MemoryStream();
        using (var src = new MemoryStream(zipBytes))
        using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
        using (var dstZip = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
        {
            foreach (var entry in srcZip.Entries)
            {
                var dst = dstZip.CreateEntry(entry.FullName);
                using var outStream = dst.Open();
                if (entry.FullName == entryToReplace)
                {
                    outStream.Write(newPayload);
                }
                else
                {
                    using var inStream = entry.Open();
                    inStream.CopyTo(outStream);
                }
            }
        }
        return output.ToArray();
    }

    [Fact]
    public async Task LoadAsync_rejects_bundle_with_tampered_manifest_field_even_with_correct_passphrase()
    {
        // T-005: a stolen bundle whose plaintext manifest fields (SourceEnvironment,
        // ExportedBy, …) have been edited must fail decryption with a tag mismatch.
        // Without AAD an attacker could rewrite the SourceEnvironment label and slip
        // past the Step-4 typo-resistant confirmation gate. We tamper the field by
        // re-packing the manifest (with everything else, including the original
        // ciphertext, unchanged) into a fresh ZIP and verify decrypt fails.
        var rig = BuildRig();
        using var packed = PackEncryptedBundle(
            rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
        var bytes = ((MemoryStream)packed).ToArray();

        // Read manifest + ciphertext from the legit bundle, mutate SourceEnvironment,
        // and re-pack with the SAME ciphertext bytes — the cipher is fine, only the
        // plaintext manifest is changed.
        BundleManifest originalManifest;
        byte[] cipherBytes;
        using (var src = new MemoryStream(bytes))
        using (var srcZip = new ZipArchive(src, ZipArchiveMode.Read))
        {
            using var ms = new MemoryStream();
            srcZip.GetEntry("manifest.json")!.Open().CopyTo(ms);
            originalManifest = JsonSerializer.Deserialize<BundleManifest>(ms.ToArray(), BundleJsonOptions)!;

            using var ctMs = new MemoryStream();
            srcZip.GetEntry("content.enc")!.Open().CopyTo(ctMs);
            cipherBytes = ctMs.ToArray();
        }

        var tampered = originalManifest with { SourceEnvironment = "prod-spoofed" };
        var tamperedManifestBytes = JsonSerializer.SerializeToUtf8Bytes(tampered, BundleJsonOptions);

        var tamperedZipBytes = new MemoryStream();
        using (var outZip = new ZipArchive(tamperedZipBytes, ZipArchiveMode.Create, leaveOpen: true))
        {
            var mEntry = outZip.CreateEntry("manifest.json");
            using (var s = mEntry.Open()) { s.Write(tamperedManifestBytes); }
            var cEntry = outZip.CreateEntry("content.enc");
            using (var s = cEntry.Open()) { s.Write(cipherBytes); }
        }
        tamperedZipBytes.Position = 0;

        // ContentHash check is the FIRST thing that catches the tamper here because
        // ContentHash is sealed against the cipher bytes and the cipher is unchanged
        // BUT the manifest's plaintext fields are mutated — wait, ContentHash is
        // against the cipher which we kept, so it still matches. The defence is
        // the AES-GCM AAD: the tag check fails because AAD differs.
        await Assert.ThrowsAnyAsync<CryptographicException>(
            () => rig.Importer.LoadAsync(tamperedZipBytes, passphrase: "correct"));
    }

    [Fact]
    public async Task LoadAsync_resets_unlock_counter_on_correct_passphrase()
    {
        // T-003: a legitimate operator who typos once or twice before getting it
        // right must not be penalised on the next bundle. A successful LoadAsync
        // clears the per-bundle counter.
        var rig = BuildRig();
        using var packed = PackEncryptedBundle(
            rig.Serializer, rig.ManifestBuilder, rig.Encryptor, EmptyContent(), "correct");
        var bundleBytes = ((MemoryStream)packed).ToArray();

        await Assert.ThrowsAnyAsync<CryptographicException>(
            () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));

        var session = await rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "correct");
        Assert.NotNull(session);

        // Counter cleared — a subsequent wrong-passphrase attempt starts from 0
        // and surfaces as CryptographicException, not BundleLockedException.
        await Assert.ThrowsAnyAsync<CryptographicException>(
            () => rig.Importer.LoadAsync(new MemoryStream(bundleBytes), passphrase: "wrong"));
        Assert.Equal(1, rig.SessionStore.GetUnlockFailureCount(session.Manifest.ContentHash));
    }

    [Fact]
    public async Task LoadAsync_throws_NotSupportedException_when_bundleFormatVersion_unsupported()
    {
        var rig = BuildRig();

        // Synthesize a zip by hand whose manifest carries an unsupported format
        // version. The validator looks at BundleFormatVersion first thing after
        // null-checks, so the content hash doesn't need to be correct for this
        // path — we just need a structurally valid manifest record.
        var content = EmptyContent();
        var contentBytes = rig.Serializer.SerializeContentBytes(content);
        var forwardManifest = new BundleManifest(
            BundleFormatVersion: 999,
            SchemaVersion: "1.0",
            CreatedAtUtc: DateTimeOffset.UtcNow,
            SourceEnvironment: "dev",
            ExportedBy: "alice",
            ScadaBridgeVersion: "1.0.0",
            ContentHash: "sha256:" + Convert.ToHexString(SHA256.HashData(contentBytes)).ToLowerInvariant(),
            Encryption: null,
            Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
            Contents: Array.Empty<ManifestContentEntry>());

        var bundleStream = HandCraftZip(forwardManifest, contentBytes, encrypted: false);

        await Assert.ThrowsAsync<NotSupportedException>(
            () => rig.Importer.LoadAsync(bundleStream, passphrase: null));
    }

    [Fact]
    public async Task LoadAsync_throws_InvalidDataException_when_content_hash_mismatch()
    {
        var rig = BuildRig();

        // Build a normal bundle, then corrupt content.json's bytes after the
        // manifest is stamped — the manifest still references the original hash.
        var content = SmallContent();
        var originalContentBytes = rig.Serializer.SerializeContentBytes(content);
        var manifest = rig.ManifestBuilder.Build(
            sourceEnvironment: "dev",
            exportedBy: "alice",
            scadaBridgeVersion: "1.0.0",
            encryption: null,
            summary: new BundleSummary(1, 0, 0, 0, 0, 0, 0, 0),
            contents: Array.Empty<ManifestContentEntry>(),
            contentBytes: originalContentBytes);

        // Corrupt the bytes so the validator's recomputed hash diverges from
        // the manifest's frozen hash.
        var corrupted = (byte[])originalContentBytes.Clone();
        corrupted[0] ^= 0xFF;

        var bundleStream = HandCraftZip(manifest, corrupted, encrypted: false);

        var ex = await Assert.ThrowsAsync<InvalidDataException>(
            () => rig.Importer.LoadAsync(bundleStream, passphrase: null));
        Assert.Contains("hash", ex.Message, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task LoadAsync_throws_InvalidOperationException_when_bundle_exceeds_size_cap()
    {
        // MaxBundleSizeMb is an int; the smallest positive value is 1 MB. Pack
        // a normal bundle and bump it past 1 MB by padding with a long
        // description, then cap the limit to 0 — the comparison is `> maxBytes`
        // so any positive byte count exceeds a 0 MB cap.
        var rig = BuildRig(opts => opts.MaxBundleSizeMb = 0);
        var stream = PackPlainBundle(rig.Serializer, rig.ManifestBuilder, SmallContent());

        await Assert.ThrowsAsync<InvalidOperationException>(
            () => rig.Importer.LoadAsync(stream, passphrase: null));
    }

    /// <summary>
    /// Builds a zip directly so the test can write a manifest whose
    /// <c>ContentHash</c> or <c>BundleFormatVersion</c> intentionally
    /// disagrees with the content bytes — paths the high-level
    /// <see cref="BundleSerializer.Pack"/> won't produce because it always
    /// re-stamps the hash itself.
    /// </summary>
    private static Stream HandCraftZip(BundleManifest manifest, byte[] contentBytes, bool encrypted)
    {
        var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, BundleJsonOptions);
        var ms = new MemoryStream();
        using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
        {
            WriteEntry(archive, "manifest.json", manifestBytes);
            WriteEntry(archive, encrypted ? "content.enc" : "content.json", contentBytes);
        }
        ms.Position = 0;
        return ms;
    }

    private static void WriteEntry(ZipArchive archive, string name, byte[] payload)
    {
        var entry = archive.CreateEntry(name, CompressionLevel.Optimal);
        using var es = entry.Open();
        es.Write(payload, 0, payload.Length);
    }
}
