using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
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 deployment-setup <see cref="ReferenceAuditFlow"/> against a real temp-SQLite full session
/// whose <see cref="StoreTarget.Configuration"/> references three <c>${secret:NAME}</c> tokens — one
/// seeded (Ok), one absent (Missing), and one tombstoned. Each flow renders to a scripted
/// <see cref="TestConsole"/>, so the tests assert both the rendered <see cref="TestConsole.Output"/> and
/// the resulting store state. The invariant under test is that the operator can fill every startup gap
/// from one screen without any typed secret value ever reaching the terminal.
/// </summary>
public sealed class ReferenceAuditFlowTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-refaudit-{Guid.NewGuid():N}");

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

    public ReferenceAuditFlowTests() => 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 status table never truncates a cell
        return console;
    }

    // A full (KEK-capable) session on a fresh temp SQLite store, with an optional in-memory configuration
    // layered onto the target so the reference auditor sees ${secret:...} tokens. Keyed by an operator-
    // override KEK so the test never depends on ambient environment state.
    private async Task<SecretsSession> NewSessionAsync(
        IEnumerable<KeyValuePair<string, string?>>? configEntries = null)
    {
        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}",
        });

        if (configEntries is not null)
        {
            IConfigurationRoot config = new ConfigurationBuilder().AddInMemoryCollection(configEntries).Build();
            target = target with { Configuration = config };
        }

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

    // The canonical three-reference configuration: svc/ok (seeded), svc/missing (absent), svc/tomb
    // (tombstoned). Ordinal name order is svc/missing < svc/ok < svc/tomb, so the non-Ok findings the
    // flow prompts for arrive in the order missing, then tomb.
    private static IEnumerable<KeyValuePair<string, string?>> ThreeReferenceConfig() =>
    [
        new("ConnectionStrings:Ok", "${secret:svc/ok}"),
        new("Api:MissingKey", "${secret:svc/missing}"),
        new("Feature:TombValue", "${secret:svc/tomb}"),
    ];

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

    // Seeds svc/ok live and leaves svc/missing absent, then seeds + tombstones svc/tomb.
    private static async Task SeedThreeReferenceStateAsync(SecretsSession session)
    {
        await SeedAsync(session, "svc/ok", "OK-SEEDED-VALUE");
        await SeedAsync(session, "svc/tomb", "TOMB-ORIGINAL-VALUE");
        await session.Store.DeleteAsync(new SecretName("svc/tomb"), "test", 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;
    }

    [Fact]
    public async Task Renders_status_table_for_all_references()
    {
        SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
        await SeedThreeReferenceStateAsync(session);

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("n"); // svc/missing: decline (this test only asserts the rendered table)
        console.Input.PushTextWithEnter("n"); // svc/tomb: decline
        await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);

        string output = console.Output;
        Assert.Contains("svc/ok", output, StringComparison.Ordinal);
        Assert.Contains("svc/missing", output, StringComparison.Ordinal);
        Assert.Contains("svc/tomb", output, StringComparison.Ordinal);
        Assert.Contains("Missing", output, StringComparison.Ordinal);
        Assert.Contains("Tombstoned", output, StringComparison.Ordinal);
        Assert.Contains("ConnectionStrings:Ok", output, StringComparison.Ordinal);
        Assert.Contains("Api:MissingKey", output, StringComparison.Ordinal);
        Assert.Contains("Feature:TombValue", output, StringComparison.Ordinal);

        Assert.True(new ReferenceAuditFlow().RequiresKek);
    }

    [Fact]
    public async Task Offers_to_seed_each_non_ok_reference_and_seeds_accepted_ones()
    {
        SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
        await SeedThreeReferenceStateAsync(session);

        TestConsole console = NewConsole();
        // svc/missing (first non-Ok): accept, value, Text (Enter), empty description.
        console.Input.PushTextWithEnter("y");
        console.Input.PushTextWithEnter("MISSING-SEED-SENTINEL");
        console.Input.PushKey(ConsoleKey.Enter);
        console.Input.PushKey(ConsoleKey.Enter);
        // svc/tomb (second non-Ok): accept, value, Text (Enter), empty description.
        console.Input.PushTextWithEnter("y");
        console.Input.PushTextWithEnter("TOMB-SEED-SENTINEL");
        console.Input.PushKey(ConsoleKey.Enter);
        console.Input.PushKey(ConsoleKey.Enter);

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

        IReadOnlyList<ReferenceFinding> after =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);
        Assert.All(after, f => Assert.Equal(ReferenceStatus.Ok, f.Status));

        // The store now decrypts both freshly-seeded values.
        StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
        StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
        Assert.Equal("MISSING-SEED-SENTINEL", session.Cipher!.Decrypt(missing!));
        Assert.Equal("TOMB-SEED-SENTINEL", session.Cipher!.Decrypt(tomb!));
        Assert.False(tomb!.IsDeleted); // upsert revived the tombstone

        Assert.DoesNotContain("MISSING-SEED-SENTINEL", console.Output, StringComparison.Ordinal);
        Assert.DoesNotContain("TOMB-SEED-SENTINEL", console.Output, StringComparison.Ordinal);

        // The closing re-audit summary reflects the now-all-Ok state.
        Assert.Contains("Audit complete: 3 Ok.", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Undecryptable_reference_is_offered_as_overwrite_and_reseeded()
    {
        KeyValuePair<string, string?>[] config = [new("Api:Bad", "${secret:svc/bad}")];

        // Seal svc/bad under a DIFFERENT KEK on the same store, so the audited session cannot decrypt it.
        SecretsSession writer = await NewSessionAsync(config);
        await SeedAsync(writer, "svc/bad", "OLD-UNREADABLE-VALUE");

        SecretsSession session = await NewSessionAsync(config); // fresh random KEK → svc/bad is Undecryptable

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("y");                     // overwrite confirm (defaults false for Undecryptable)
        console.Input.PushTextWithEnter("NEW-READABLE-SENTINEL"); // fresh value (masked)
        console.Input.PushKey(ConsoleKey.Enter);                  // content-type Text
        console.Input.PushKey(ConsoleKey.Enter);                  // description empty

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

        Assert.Contains("Undecryptable", console.Output, StringComparison.Ordinal); // rendered in the table

        StoredSecret? row = await session.Store.GetAsync(new SecretName("svc/bad"), CancellationToken.None);
        Assert.Equal("NEW-READABLE-SENTINEL", session.Cipher!.Decrypt(row!)); // now decrypts under the session KEK
        Assert.DoesNotContain("NEW-READABLE-SENTINEL", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Invalid_name_reference_renders_hint_and_is_not_prompted()
    {
        KeyValuePair<string, string?>[] config = [new("Api:Broken", "${secret:REPLACE ME}")];
        SecretsSession session = await NewSessionAsync(config);

        TestConsole console = NewConsole(); // push NO input: an InvalidName reference must never prompt
        await new ReferenceAuditFlow().RunAsync(console, session, CancellationToken.None);

        Assert.Contains("InvalidName", console.Output, StringComparison.Ordinal);        // table status
        Assert.Contains("not a valid secret name", console.Output, StringComparison.Ordinal); // fix-the-token hint
        Assert.Equal(0, CountOccurrences(console.Output, "Seed '"));                     // no seed prompt
        Assert.Contains("Audit complete: 1 InvalidName.", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Skipped_references_are_left_untouched()
    {
        SecretsSession session = await NewSessionAsync(ThreeReferenceConfig());
        await SeedThreeReferenceStateAsync(session);

        TestConsole console = NewConsole();
        console.Input.PushTextWithEnter("n"); // svc/missing: decline
        console.Input.PushTextWithEnter("n"); // svc/tomb: decline

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

        StoredSecret? missing = await session.Store.GetAsync(new SecretName("svc/missing"), CancellationToken.None);
        StoredSecret? tomb = await session.Store.GetAsync(new SecretName("svc/tomb"), CancellationToken.None);
        Assert.Null(missing);
        Assert.NotNull(tomb);
        Assert.True(tomb!.IsDeleted);
    }

    [Fact]
    public async Task Manual_target_without_appsettings_reports_nothing_to_audit()
    {
        SecretsSession session = await NewSessionAsync(); // Manual target: empty composed configuration

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

        Assert.Contains("No ${secret:} references found", console.Output, StringComparison.Ordinal);
        Assert.Equal(0, CountOccurrences(console.Output, "Seed '")); // no seed prompt was rendered
    }
}
