using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Sqlite;

namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;

/// <summary>
/// Exercises <see cref="BundleService"/> against real temp-dir SQLite stores: the same-KEK
/// export/import round-trip, the no-plaintext-at-rest guarantee, tombstone filtering,
/// last-writer-wins conflict resolution (and the per-row override that can force a bundle row),
/// and cross-KEK import (rewrap when the source KEK is supplied, skip-and-report when it is not).
/// </summary>
public sealed class BundleServiceTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-bundle-{Guid.NewGuid():N}");

    public BundleServiceTests() => Directory.CreateDirectory(_dir);

    public void Dispose()
    {
        if (Directory.Exists(_dir))
        {
            try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
        }
    }

    private string Db(string name) => Path.Combine(_dir, name);

    private string BundlePath => Path.Combine(_dir, "bundle.json");

    private static LiteralMasterKeyProvider NewKek()
    {
        byte[] key = new byte[32];
        RandomNumberGenerator.Fill(key);
        return new LiteralMasterKeyProvider(Convert.ToBase64String(key));
    }

    private static SecretsSession BuildSession(string dbPath, LiteralMasterKeyProvider kek)
    {
        var factory = new SecretsSqliteConnectionFactory(dbPath);
        var migrator = new SqliteSecretsStoreMigrator(factory);
        migrator.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
        return new SecretsSession
        {
            Target = new TargetConfigReader().Manual(dbPath, new MasterKeyOptions()),
            Store = new SqliteSecretStore(factory),
            Migrator = migrator,
            MasterKey = kek,
            Cipher = new AesGcmEnvelopeCipher(kek),
            StoreKind = "sqlite",
        };
    }

    private static async Task SealAsync(SecretsSession session, string name, string value)
    {
        StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text);
        await session.Store.UpsertAsync(row, CancellationToken.None);
    }

    private void WriteBundle(string sourceKekId, params StoredSecret[] rows)
    {
        var bundle = new SecretBundle
        {
            ExportedUtc = DateTimeOffset.UtcNow,
            SourceKekId = sourceKekId,
            Entries = rows.Select(SecretBundleCodec.FromRow).ToList(),
        };
        File.WriteAllText(BundlePath, SecretBundleCodec.Serialize(bundle));
    }

    [Fact]
    public async Task Export_then_import_into_empty_store_roundtrips_rows()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kek);

        // Seed non-trivial metadata: created_by/updated_by set, and a second update so Revision == 1
        // and created_utc != updated_utc — makes the "verbatim" assertion below actually load-bearing.
        StoredSecret seed = source.Cipher!.Encrypt(new SecretName("svc/one"), "value-one", SecretContentType.Text)
            with { Description = "the one", CreatedBy = "creator", UpdatedBy = "updater" };
        await source.Store.UpsertAsync(seed, CancellationToken.None);
        await source.Store.UpsertAsync(seed, CancellationToken.None);
        await SealAsync(source, "svc/two", "value-two");

        var service = new BundleService();
        int exported = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
        Assert.Equal(2, exported);

        // Same KEK, fresh empty store.
        SecretsSession target = BuildSession(Db("b.db"), kek);
        BundleImportReport report =
            await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
        Assert.Equal(2, report.Imported);
        Assert.Equal(0, report.SkippedForeignKek);

        foreach ((string name, string value) in new[] { ("svc/one", "value-one"), ("svc/two", "value-two") })
        {
            StoredSecret? src = await source.Store.GetAsync(new SecretName(name), CancellationToken.None);
            StoredSecret? dst = await target.Store.GetAsync(new SecretName(name), CancellationToken.None);
            Assert.NotNull(src);
            Assert.NotNull(dst);

            // Crypto + identity fields.
            Assert.Equal(src!.Name.Value, dst!.Name.Value);
            Assert.Equal(src.ContentType, dst.ContentType);
            Assert.Equal(src.Description, dst.Description);
            Assert.Equal(src.KekId, dst.KekId);
            Assert.Equal(src.Ciphertext, dst.Ciphertext);
            Assert.Equal(src.Nonce, dst.Nonce);
            Assert.Equal(src.Tag, dst.Tag);
            Assert.Equal(src.WrappedDek, dst.WrappedDek);
            Assert.Equal(src.WrapNonce, dst.WrapNonce);
            Assert.Equal(src.WrapTag, dst.WrapTag);

            // Verbatim import: LWW metadata + audit stamps survive unchanged (ApplyReplicatedAsync).
            Assert.Equal(src.Revision, dst.Revision);
            Assert.Equal(src.CreatedUtc, dst.CreatedUtc);
            Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);
            Assert.Equal(src.IsDeleted, dst.IsDeleted);
            Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
            Assert.Equal(src.CreatedBy, dst.CreatedBy);
            Assert.Equal(src.UpdatedBy, dst.UpdatedBy);

            // And it decrypts to the same plaintext under the same KEK.
            Assert.Equal(value, target.Cipher!.Decrypt(dst));
        }

        // The twice-updated row genuinely exercised non-default metadata.
        StoredSecret one = (await target.Store.GetAsync(new SecretName("svc/one"), CancellationToken.None))!;
        Assert.Equal(1, one.Revision);
        Assert.Equal("creator", one.CreatedBy);
        Assert.Equal("updater", one.UpdatedBy);
    }

    [Fact]
    public async Task Import_preserves_tombstones_verbatim()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kek);
        await SealAsync(source, "svc/dead", "dead-value");
        await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: "remover", CancellationToken.None);
        StoredSecret src = (await source.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;

        var service = new BundleService();
        int exported = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
        Assert.Equal(1, exported);

        SecretsSession target = BuildSession(Db("b.db"), kek);
        BundleImportReport report =
            await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
        Assert.Equal(1, report.Imported);

        // The imported row stays a tombstone (no resurrection) with its delete metadata intact.
        StoredSecret dst = (await target.Store.GetAsync(new SecretName("svc/dead"), CancellationToken.None))!;
        Assert.True(dst.IsDeleted);
        Assert.Equal(src.DeletedUtc, dst.DeletedUtc);
        Assert.Equal(src.Revision, dst.Revision);
        Assert.Equal(src.UpdatedUtc, dst.UpdatedUtc);

        // And it is not listed as a live secret.
        IReadOnlyList<SecretMetadata> live = await target.Store.ListAsync(includeDeleted: false, CancellationToken.None);
        Assert.Empty(live);
    }

    [Fact]
    public async Task Import_rejects_unknown_format_version()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession target = BuildSession(Db("b.db"), kek);

        const string json = """
            {
              "FormatVersion": 99,
              "ExportedUtc": "2026-01-01T00:00:00+00:00",
              "SourceKekId": "kek",
              "Entries": []
            }
            """;
        await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);

        var service = new BundleService();
        InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
        Assert.Contains("99", ex.Message, StringComparison.Ordinal);
        Assert.Contains("1", ex.Message, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Import_malformed_entry_fails_with_entry_context()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession target = BuildSession(Db("b.db"), kek);

        // A well-formed bundle envelope carrying one entry with non-base64 ciphertext.
        const string json = """
            {
              "FormatVersion": 1,
              "ExportedUtc": "2026-01-01T00:00:00+00:00",
              "SourceKekId": "kek",
              "Entries": [
                {
                  "Name": "svc/broken", "Description": null, "ContentType": "Text",
                  "Ciphertext": "@@not-base64@@", "Nonce": "AAAA", "Tag": "AAAA",
                  "WrappedDek": "AAAA", "WrapNonce": "AAAA", "WrapTag": "AAAA",
                  "KekId": "kek", "Revision": 0, "IsDeleted": false, "DeletedUtc": null,
                  "CreatedUtc": "2026-01-01T00:00:00+00:00", "UpdatedUtc": "2026-01-01T00:00:00+00:00",
                  "CreatedBy": null, "UpdatedBy": null
                }
              ]
            }
            """;
        await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);

        var service = new BundleService();
        InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None));
        Assert.Contains("svc/broken", ex.Message, StringComparison.Ordinal);
        Assert.Contains(BundlePath, ex.Message, StringComparison.Ordinal);

        // The store was left untouched.
        Assert.Null(await target.Store.GetAsync(new SecretName("svc/broken"), CancellationToken.None));
    }

    [Fact]
    public async Task Bundle_json_contains_no_plaintext()
    {
        const string sentinel = "SENTINEL-3f9a2c-DO-NOT-LEAK";
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kek);
        await SealAsync(source, "svc/secret", sentinel);

        var service = new BundleService();
        await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);

        string raw = await File.ReadAllTextAsync(BundlePath, CancellationToken.None);
        Assert.DoesNotContain(sentinel, raw, StringComparison.Ordinal);

        // The base64 ciphertext of the sealed row is present in the file. Assert structurally through
        // the codec rather than by raw-text match: System.Text.Json escapes '+' as +, so a raw
        // Contains on the base64 string is flaky whenever the ciphertext base64 contains a '+'.
        StoredSecret? row = await source.Store.GetAsync(new SecretName("svc/secret"), CancellationToken.None);
        Assert.NotNull(row);
        Assert.Contains("\"Ciphertext\"", raw, StringComparison.Ordinal);
        SecretBundle parsed = SecretBundleCodec.Deserialize(raw);
        BundleEntry parsedEntry = Assert.Single(parsed.Entries);
        Assert.Equal(Convert.ToBase64String(row!.Ciphertext), parsedEntry.Ciphertext);
    }

    [Fact]
    public async Task Export_excludes_tombstones_by_default_and_includes_them_when_asked()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kek);
        await SealAsync(source, "svc/live", "live-value");
        await SealAsync(source, "svc/dead", "dead-value");
        await source.Store.DeleteAsync(new SecretName("svc/dead"), actor: null, CancellationToken.None);

        var service = new BundleService();

        int excluded = await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);
        Assert.Equal(1, excluded);
        SecretBundle withoutDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
        Assert.Equal(["svc/live"], withoutDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());

        int included = await service.ExportAsync(source, BundlePath, includeDeleted: true, CancellationToken.None);
        Assert.Equal(2, included);
        SecretBundle withDeleted = SecretBundleCodec.Deserialize(await File.ReadAllTextAsync(BundlePath, CancellationToken.None));
        Assert.Equal(["svc/dead", "svc/live"], withDeleted.Entries.Select(e => e.Name).OrderBy(n => n).ToArray());
    }

    [Fact]
    public async Task Import_conflict_resolved_by_last_writer_wins()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession target = BuildSession(Db("b.db"), kek);
        await SealAsync(target, "svc/conf", "existing-value");
        StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;

        var service = new BundleService();

        // Incoming OLDER than the existing row -> existing survives.
        StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "older-bundle-value", SecretContentType.Text)
            with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
        WriteBundle(kek.KekId, older);
        BundleImportReport olderReport =
            await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
        Assert.Equal(0, olderReport.Imported);
        Assert.Equal(1, olderReport.SkippedOlder);
        Assert.Equal(1, olderReport.Conflicts);
        StoredSecret afterOlder = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
        Assert.Equal("existing-value", target.Cipher!.Decrypt(afterOlder));

        // Incoming NEWER than the existing row -> bundle row replaces it.
        StoredSecret newer = target.Cipher!.Encrypt(new SecretName("svc/conf"), "newer-bundle-value", SecretContentType.Text)
            with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(1), Revision = existing.Revision };
        WriteBundle(kek.KekId, newer);
        BundleImportReport newerReport =
            await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);
        Assert.Equal(1, newerReport.Imported);
        Assert.Equal(0, newerReport.SkippedOlder);
        Assert.Equal(1, newerReport.Conflicts);
        StoredSecret afterNewer = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
        Assert.Equal("newer-bundle-value", target.Cipher!.Decrypt(afterNewer));
    }

    [Fact]
    public async Task Import_conflict_callback_can_force_bundle_row()
    {
        LiteralMasterKeyProvider kek = NewKek();
        SecretsSession target = BuildSession(Db("b.db"), kek);
        await SealAsync(target, "svc/conf", "existing-value");
        StoredSecret existing = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;

        // Incoming is OLDER, so LWW would skip it — but the override forces take-incoming.
        StoredSecret older = target.Cipher!.Encrypt(new SecretName("svc/conf"), "forced-bundle-value", SecretContentType.Text)
            with { UpdatedUtc = existing.UpdatedUtc.AddMinutes(-1), Revision = existing.Revision };
        WriteBundle(kek.KekId, older);

        var service = new BundleService();
        BundleImportReport report = await service.ImportAsync(
            target, BundlePath, sourceKek: null, conflictOverride: (_, _) => true, CancellationToken.None);

        Assert.Equal(1, report.Imported);
        Assert.Equal(0, report.SkippedOlder);
        Assert.Equal(1, report.Conflicts);
        StoredSecret after = (await target.Store.GetAsync(new SecretName("svc/conf"), CancellationToken.None))!;
        Assert.Equal("forced-bundle-value", target.Cipher!.Decrypt(after));
    }

    [Fact]
    public async Task Cross_kek_import_rewraps_when_source_kek_supplied()
    {
        LiteralMasterKeyProvider kekA = NewKek();
        LiteralMasterKeyProvider kekB = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kekA);
        await SealAsync(source, "svc/cross", "cross-value");

        var service = new BundleService();
        await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);

        SecretsSession target = BuildSession(Db("b.db"), kekB);
        BundleImportReport report =
            await service.ImportAsync(target, BundlePath, sourceKek: kekA, conflictOverride: null, CancellationToken.None);

        Assert.Equal(1, report.Imported);
        Assert.Equal(0, report.SkippedForeignKek);
        StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
        Assert.NotNull(dst);
        Assert.Equal(kekB.KekId, dst!.KekId);
        Assert.Equal("cross-value", target.Cipher!.Decrypt(dst));
    }

    [Fact]
    public async Task Cross_kek_import_without_source_kek_skips_rows_and_reports_them()
    {
        LiteralMasterKeyProvider kekA = NewKek();
        LiteralMasterKeyProvider kekB = NewKek();
        SecretsSession source = BuildSession(Db("a.db"), kekA);
        await SealAsync(source, "svc/cross", "cross-value");

        var service = new BundleService();
        await service.ExportAsync(source, BundlePath, includeDeleted: false, CancellationToken.None);

        SecretsSession target = BuildSession(Db("b.db"), kekB);
        BundleImportReport report =
            await service.ImportAsync(target, BundlePath, sourceKek: null, conflictOverride: null, CancellationToken.None);

        Assert.Equal(0, report.Imported);
        Assert.Equal(1, report.SkippedForeignKek);
        StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
        Assert.Null(dst);
    }
}
