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 four CRUD flows (<see cref="ListSecretsFlow"/>, <see cref="SetSecretFlow"/>,
/// <see cref="RevealSecretFlow"/>, <see cref="DeleteSecretFlow"/>) directly against a real temp-SQLite
/// full session (KEK supplied as a <see cref="LiteralMasterKeyProvider"/> override). 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 value plaintext only ever reaches
/// the screen through the reveal flow's explicit confirmation.
/// </summary>
public sealed class CrudFlowTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-crud-{Guid.NewGuid():N}");

    private string DbPath => Path.Combine(_dir, "secrets.db");

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

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

    private static TestConsole NewConsole()
    {
        var console = (TestConsole)new TestConsole().Interactive();
        console.Profile.Width = 240; // wide enough that the metadata table never truncates a cell
        return console;
    }

    // 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> NewFullSessionAsync()
    {
        byte[] key = new byte[32];
        RandomNumberGenerator.Fill(key);
        var kek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));

        StoreTarget target = new TargetConfigReader().Manual(DbPath, 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 SeedAsync(
        SecretsSession session, string name, string value, SecretContentType contentType = SecretContentType.Text)
    {
        var secretName = new SecretName(name);
        StoredSecret row = session.Cipher!.Encrypt(secretName, value, contentType);
        await session.Store.UpsertAsync(row, CancellationToken.None);
    }

    private static int CountOccurrences(string haystack, string needle)
    {
        int count = 0;
        for (int i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
             i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
        {
            count++;
        }

        return count;
    }

    // --- List ---

    [Fact]
    public async Task Renders_metadata_table_never_values()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "db/conn", "SENTINEL-CONN-VALUE", SecretContentType.ConnectionString);
        await SeedAsync(session, "api/key", "SENTINEL-API-VALUE");

        TestConsole console = NewConsole();
        await new ListSecretsFlow().RunAsync(console, session, CancellationToken.None);

        string output = console.Output;
        Assert.Contains("db/conn", output, StringComparison.Ordinal);
        Assert.Contains("api/key", output, StringComparison.Ordinal);
        Assert.Contains("ConnectionString", output, StringComparison.Ordinal);
        Assert.Contains(session.MasterKey!.KekId, output, StringComparison.Ordinal); // kek id column

        Assert.DoesNotContain("SENTINEL-CONN-VALUE", output, StringComparison.Ordinal);
        Assert.DoesNotContain("SENTINEL-API-VALUE", output, StringComparison.Ordinal);

        Assert.False(new ListSecretsFlow().RequiresKek);
    }

    // --- Set / rotate ---

    [Fact]
    public async Task Masked_prompt_seals_and_upserts()
    {
        SecretsSession session = await NewFullSessionAsync();

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("app/token");          // name
        console.Input.PushTextWithEnter("TOPSECRET-PLAINTEXT"); // value (masked)
        console.Input.PushKey(ConsoleKey.Enter);               // content-type: first choice (Text)
        console.Input.PushKey(ConsoleKey.Enter);               // description: empty

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

        StoredSecret? row = await session.Store.GetAsync(new SecretName("app/token"), CancellationToken.None);
        Assert.NotNull(row);
        Assert.Equal("TOPSECRET-PLAINTEXT", session.Cipher!.Decrypt(row!));
        Assert.DoesNotContain("TOPSECRET-PLAINTEXT", console.Output, StringComparison.Ordinal); // never echoed
    }

    [Fact]
    public async Task Invalid_name_reprompts_then_completes()
    {
        SecretsSession session = await NewFullSessionAsync();

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("bad name");            // invalid (space) → must re-prompt, not throw
        console.Input.PushTextWithEnter("good/name");           // valid retry
        console.Input.PushTextWithEnter("VALUE-AFTER-REPROMPT"); // value
        console.Input.PushKey(ConsoleKey.Enter);               // content-type: Text
        console.Input.PushKey(ConsoleKey.Enter);               // description: empty

        // The bug this pins: SecretName's error text embeds "[a-z0-9._/-]", which Spectre parses as
        // markup and throws InvalidOperationException on unless the validator escapes it. Reaching the
        // assertion at all proves the re-prompt happened cleanly.
        await new SetSecretFlow().RunAsync(console, session, CancellationToken.None);

        StoredSecret? row = await session.Store.GetAsync(new SecretName("good/name"), CancellationToken.None);
        Assert.NotNull(row);
        Assert.Equal("VALUE-AFTER-REPROMPT", session.Cipher!.Decrypt(row!));
    }

    [Fact]
    public async Task Existing_name_confirms_overwrite()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "shared/api", "OLD-VALUE");

        // Decline: the value must be left unchanged.
        TestConsole declineConsole = NewConsole();
        declineConsole.Input.PushTextWithEnter("shared/api"); // name (exists)
        declineConsole.Input.PushTextWithEnter("n");          // overwrite? no
        await new SetSecretFlow().RunAsync(declineConsole, session, CancellationToken.None);

        StoredSecret? afterDecline = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
        Assert.Equal("OLD-VALUE", session.Cipher!.Decrypt(afterDecline!));

        // Accept: the value is overwritten in place.
        TestConsole acceptConsole = NewConsole();
        acceptConsole.Input.PushTextWithEnter("shared/api");  // name (exists)
        acceptConsole.Input.PushTextWithEnter("y");           // overwrite? yes
        acceptConsole.Input.PushTextWithEnter("NEW-VALUE");   // value (masked)
        acceptConsole.Input.PushKey(ConsoleKey.Enter);        // content-type: Text
        acceptConsole.Input.PushKey(ConsoleKey.Enter);        // description: empty
        await new SetSecretFlow().RunAsync(acceptConsole, session, CancellationToken.None);

        StoredSecret? afterAccept = await session.Store.GetAsync(new SecretName("shared/api"), CancellationToken.None);
        Assert.Equal("NEW-VALUE", session.Cipher!.Decrypt(afterAccept!));
    }

    // --- Reveal ---

    [Fact]
    public async Task Confirm_then_prints_value_once()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("reveal/me"); // name
        console.Input.PushTextWithEnter("y");         // reveal on screen? yes

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

        Assert.Equal(1, CountOccurrences(console.Output, "REVEAL-SENTINEL-XYZ"));
    }

    [Fact]
    public async Task Decline_prints_nothing()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "reveal/me", "REVEAL-SENTINEL-XYZ");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("reveal/me"); // name
        console.Input.PushTextWithEnter("n");         // reveal on screen? no

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

        Assert.DoesNotContain("REVEAL-SENTINEL-XYZ", console.Output, StringComparison.Ordinal);
    }

    // --- Delete ---

    [Fact]
    public async Task Confirm_tombstones_row()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "del/me", "GONE");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("del/me"); // name
        console.Input.PushTextWithEnter("y");      // delete? yes

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

        StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
        Assert.NotNull(row);
        Assert.True(row!.IsDeleted);
    }

    [Fact]
    public async Task Unknown_name_reports_not_found_without_confirming()
    {
        SecretsSession session = await NewFullSessionAsync();

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("never/existed"); // name only — no confirm input is scripted
        // If the flow prompted for a confirm before checking existence, the missing input would fail.
        await new DeleteSecretFlow().RunAsync(console, session, CancellationToken.None);

        Assert.Contains("No secret named", console.Output, StringComparison.Ordinal);
        StoredSecret? row = await session.Store.GetAsync(new SecretName("never/existed"), CancellationToken.None);
        Assert.Null(row);
    }

    [Fact]
    public async Task Decline_leaves_row()
    {
        SecretsSession session = await NewFullSessionAsync();
        await SeedAsync(session, "del/me", "STAYS");

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("del/me"); // name
        console.Input.PushTextWithEnter("n");      // delete? no

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

        StoredSecret? row = await session.Store.GetAsync(new SecretName("del/me"), CancellationToken.None);
        Assert.NotNull(row);
        Assert.False(row!.IsDeleted);
    }
}
