using System.Security.Cryptography;
using Spectre.Console;
using Spectre.Console.Testing;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
using ZB.MOM.WW.Secrets.MasterKey;

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

/// <summary>
/// Drives the two bundle flows (<see cref="BundleExportFlow"/>, <see cref="BundleImportFlow"/>) directly
/// against real temp-SQLite full sessions. Export writes a ciphertext-only bundle to a scripted path;
/// import reconciles into a second store — exercising last-writer-wins, the per-row conflict prompt, and
/// the foreign-KEK detection that prompts for the source key and re-wraps. Each flow renders to a scripted
/// <see cref="TestConsole"/>, so the tests assert on rendered <see cref="TestConsole.Output"/> and on the
/// resulting store state — the invariant under test is that a pasted source key never reaches the screen.
/// </summary>
public sealed class BundleFlowTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-bundleflow-{Guid.NewGuid():N}");

    public BundleFlowTests() => 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 TestConsole NewConsole()
    {
        var console = (TestConsole)new TestConsole().Interactive();
        console.Profile.Width = 240; // wide enough that the report table never truncates a cell
        return console;
    }

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

    // A full (KEK-capable) session on a fresh temp SQLite store, keyed by an operator-override KEK so the
    // test never depends on ambient environment state.
    private async Task<SecretsSession> NewSessionAsync(string dbName, LiteralMasterKeyProvider kek)
    {
        StoreTarget target = new TargetConfigReader().Manual(Db(dbName), new MasterKeyOptions
        {
            Source = MasterKeySource.Environment,
            EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
        });

        return await new SecretsSessionFactory().OpenAsync(target, kek, CancellationToken.None);
    }

    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);
    }

    // Writes a row into a store verbatim (bypassing the revision bump) so a test can pin an explicit
    // UpdatedUtc/Revision — used to make a target row deterministically newer than an incoming bundle row.
    private static async Task ApplyNewerAsync(SecretsSession session, string name, string value)
    {
        StoredSecret row = session.Cipher!.Encrypt(new SecretName(name), value, SecretContentType.Text) with
        {
            UpdatedUtc = DateTimeOffset.UtcNow.AddDays(1),
            CreatedUtc = DateTimeOffset.UtcNow.AddDays(1),
            Revision = 7,
        };
        await session.Store.ApplyReplicatedAsync(row, CancellationToken.None);
    }

    [Fact]
    public async Task Export_prompts_for_path_and_reports_row_count()
    {
        LiteralMasterKeyProvider kek = NewKek(out _);
        SecretsSession session = await NewSessionAsync("a.db", kek);
        await SealAsync(session, "svc/one", "value-one");
        await SealAsync(session, "svc/two", "value-two");

        string outPath = Path.Combine(_dir, "typed-bundle.json");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(outPath); // path (overrides the offered default)
        console.Input.PushTextWithEnter("n");     // include tombstoned? no

        await new BundleExportFlow().RunAsync(console, session, CancellationToken.None);

        Assert.True(new BundleExportFlow().RequiresKek);
        Assert.True(File.Exists(outPath));
        Assert.Contains("Exported 2 row(s)", console.Output, StringComparison.Ordinal);
        // The offered default filename carries the target name and the .json extension.
        Assert.Contains($"secrets-bundle-{session.Target.Name}", console.Output, StringComparison.Ordinal);
        Assert.Contains(".json", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Import_reports_counts_and_applies_lww()
    {
        LiteralMasterKeyProvider kek = NewKek(out _);
        SecretsSession source = await NewSessionAsync("a.db", kek);
        await SealAsync(source, "svc/x", "bundle-older-value");

        // Export the bundle from the source store (same KEK as the target).
        var exportConsole = NewConsole();
        exportConsole.Input.PushTextWithEnter(BundlePath);
        exportConsole.Input.PushTextWithEnter("n");
        await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);

        // Target holds a newer conflicting row: LWW must keep it and skip the bundle row.
        SecretsSession target = await NewSessionAsync("b.db", kek);
        await ApplyNewerAsync(target, "svc/x", "local-newer-value");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(BundlePath); // path (same KEK -> no source-key prompt)
        console.Input.PushTextWithEnter("n");        // prompt per conflict? no -> pure LWW

        await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

        Assert.Contains("Imported", console.Output, StringComparison.Ordinal); // report table rendered
        StoredSecret? after = await target.Store.GetAsync(new SecretName("svc/x"), CancellationToken.None);
        Assert.NotNull(after);
        Assert.Equal("local-newer-value", target.Cipher!.Decrypt(after!)); // LWW kept the newer local row
    }

    [Fact]
    public async Task Import_conflict_prompt_lets_operator_pick_per_row()
    {
        LiteralMasterKeyProvider kek = NewKek(out _);
        SecretsSession source = await NewSessionAsync("a.db", kek);
        await SealAsync(source, "svc/a", "bundle-a");
        await SealAsync(source, "svc/b", "bundle-b");

        var exportConsole = NewConsole();
        exportConsole.Input.PushTextWithEnter(BundlePath);
        exportConsole.Input.PushTextWithEnter("n");
        await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);

        // Both names already exist in the target (as newer rows) -> both are conflicts.
        SecretsSession target = await NewSessionAsync("b.db", kek);
        await ApplyNewerAsync(target, "svc/a", "local-a");
        await ApplyNewerAsync(target, "svc/b", "local-b");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(BundlePath); // path
        console.Input.PushTextWithEnter("y");        // prompt per conflict? yes
        // Bundle entries arrive in name order: svc/a first, svc/b second.
        console.Input.PushTextWithEnter("n");        // svc/a: take the bundle row? no  -> keep local
        console.Input.PushTextWithEnter("y");        // svc/b: take the bundle row? yes -> take bundle

        await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

        StoredSecret? a = await target.Store.GetAsync(new SecretName("svc/a"), CancellationToken.None);
        StoredSecret? b = await target.Store.GetAsync(new SecretName("svc/b"), CancellationToken.None);
        Assert.Equal("local-a", target.Cipher!.Decrypt(a!));  // kept local
        Assert.Equal("bundle-b", target.Cipher!.Decrypt(b!)); // took the bundle
    }

    [Fact]
    public async Task Import_foreign_kek_prompts_for_source_key_then_rewraps()
    {
        LiteralMasterKeyProvider kekA = NewKek(out string base64A);
        LiteralMasterKeyProvider kekB = NewKek(out _);
        Assert.NotEqual(kekA.KekId, kekB.KekId);

        SecretsSession source = await NewSessionAsync("a.db", kekA);
        await SealAsync(source, "svc/cross", "cross-value");

        var exportConsole = NewConsole();
        exportConsole.Input.PushTextWithEnter(BundlePath);
        exportConsole.Input.PushTextWithEnter("n");
        await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);

        // Session on KEK-B: the flow must detect the foreign source KEK and prompt for KEK-A.
        SecretsSession target = await NewSessionAsync("b.db", kekB);

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(BundlePath); // path
        console.Input.PushKey(ConsoleKey.Enter);     // source-key selection: first choice (paste base64)
        console.Input.PushTextWithEnter(base64A);    // pasted KEK-A material (masked)
        console.Input.PushTextWithEnter("n");        // prompt per conflict? no (empty target anyway)

        await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

        StoredSecret? dst = await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None);
        Assert.NotNull(dst);
        Assert.Equal(kekB.KekId, dst!.KekId);                        // re-wrapped under the session KEK
        Assert.Equal("cross-value", target.Cipher!.Decrypt(dst));   // and decrypts

        Assert.DoesNotContain(base64A, console.Output, StringComparison.Ordinal); // pasted key never echoed
    }

    [Fact]
    public async Task Import_wrong_source_key_is_rejected_without_importing()
    {
        LiteralMasterKeyProvider kekA = NewKek(out _);
        LiteralMasterKeyProvider kekB = NewKek(out _);
        LiteralMasterKeyProvider wrong = NewKek(out string wrongBase64); // valid format, unrelated key
        Assert.NotEqual(kekA.KekId, wrong.KekId);

        SecretsSession source = await NewSessionAsync("a.db", kekA);
        await SealAsync(source, "svc/cross", "cross-value");

        var exportConsole = NewConsole();
        exportConsole.Input.PushTextWithEnter(BundlePath);
        exportConsole.Input.PushTextWithEnter("n");
        await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);

        SecretsSession target = await NewSessionAsync("b.db", kekB);

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(BundlePath);  // path
        console.Input.PushKey(ConsoleKey.Enter);      // source-key selection: paste base64
        console.Input.PushTextWithEnter(wrongBase64); // a valid-format but wrong key

        await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

        Assert.Contains("does not match this bundle's source KEK", console.Output, StringComparison.Ordinal);
        Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
        Assert.Null(await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None)); // untouched
    }

    [Fact]
    public async Task Import_source_key_file_permission_denied_renders_friendly_error_without_importing()
    {
        Skip.IfNot(!OperatingSystem.IsWindows(), "Unix permission bits are exercised via UnixFileMode.");

        LiteralMasterKeyProvider kekA = NewKek(out _);
        LiteralMasterKeyProvider kekB = NewKek(out _);

        SecretsSession source = await NewSessionAsync("a.db", kekA);
        await SealAsync(source, "svc/cross", "cross-value");

        var exportConsole = NewConsole();
        exportConsole.Input.PushTextWithEnter(BundlePath);
        exportConsole.Input.PushTextWithEnter("n");
        await new BundleExportFlow().RunAsync(exportConsole, source, CancellationToken.None);

        SecretsSession target = await NewSessionAsync("b.db", kekB);

        // A source-key file that exists (so FileMasterKeyProvider's File.Exists gate passes) but has no
        // read permission: File.ReadAllBytes inside ResolveKeyBytes throws UnauthorizedAccessException,
        // which FileMasterKeyProvider does not itself wrap into MasterKeyUnavailableException.
        string keyFilePath = Path.Combine(_dir, $"nokey-{Guid.NewGuid():N}");
        await File.WriteAllBytesAsync(keyFilePath, new byte[32], CancellationToken.None);
        File.SetUnixFileMode(keyFilePath, UnixFileMode.None);
        try
        {
            TestConsole console = NewConsole();
            console.Input.PushTextWithEnter(BundlePath);         // path
            console.Input.PushKey(ConsoleKey.DownArrow);         // source-key selection: skip "Paste base64 key"
            console.Input.PushKey(ConsoleKey.DownArrow);         // skip "Environment variable"
            console.Input.PushKey(ConsoleKey.Enter);             // choose "Key file path"
            console.Input.PushTextWithEnter(keyFilePath);

            await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

            Assert.Contains("Source KEK unavailable", console.Output, StringComparison.Ordinal); // contained
            Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
            Assert.Null(await target.Store.GetAsync(new SecretName("svc/cross"), CancellationToken.None)); // untouched
        }
        finally
        {
            File.SetUnixFileMode(keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
            File.Delete(keyFilePath);
        }
    }

    [Fact]
    public async Task Import_rejects_unknown_format_version_before_prompting()
    {
        LiteralMasterKeyProvider kek = NewKek(out _);
        SecretsSession target = await NewSessionAsync("b.db", kek);

        // A hand-built bundle envelope on an unreadable format version. Only the path is scripted: if the
        // flow prompted for anything past the format gate, the missing input would fail the test.
        const string json = """
            {
              "FormatVersion": 99,
              "ExportedUtc": "2026-01-01T00:00:00+00:00",
              "SourceKekId": "some-kek",
              "Entries": []
            }
            """;
        await File.WriteAllTextAsync(BundlePath, json, CancellationToken.None);

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter(BundlePath); // path only — no further prompt must be consumed

        await new BundleImportFlow().RunAsync(console, target, CancellationToken.None);

        Assert.Contains("99", console.Output, StringComparison.Ordinal);
        Assert.Contains("format version", console.Output, StringComparison.Ordinal);
        Assert.DoesNotContain("Imported", console.Output, StringComparison.Ordinal); // no report rendered
    }
}
