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.MasterKey;

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

/// <summary>
/// Drives <see cref="InteractiveShell"/> end-to-end through a scripted <see cref="TestConsole"/>: the
/// target picker (recents-first + manual entry), the header/menu loop, flow dispatch, the degraded-session
/// KEK-upgrade prompt, and the error-containment panel. The shell is the only UI-owning class, so these
/// tests assert on rendered <see cref="TestConsole.Output"/> and on what a fake flow observed.
/// </summary>
[Collection("Process environment")]
public sealed class InteractiveShellTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-shell-{Guid.NewGuid():N}");
    private readonly List<string> _envVars = [];

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

    public void Dispose()
    {
        foreach (string name in _envVars)
        {
            Environment.SetEnvironmentVariable(name, null);
        }

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

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

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

    private string SetTempKeyEnv()
    {
        string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
        Environment.SetEnvironmentVariable(name, NewKeyBase64());
        _envVars.Add(name);
        return name;
    }

    private string UnsetEnvVarName()
    {
        string name = $"ZB_TEST_UNSET_{Guid.NewGuid():N}";
        _envVars.Add(name); // ensure cleanup even though never set
        return name;
    }

    private string WriteAppSettings(string envVarName)
    {
        string path = Path.Combine(_dir, "appsettings.json");
        File.WriteAllText(path, $$"""
            {
              "Secrets": {
                "SqlitePath": "secrets.db",
                "MasterKey": { "Source": "Environment", "EnvVarName": "{{envVarName}}" }
              }
            }
            """);
        return path;
    }

    private static TestConsole NewConsole() => new TestConsole().Interactive();

    private static InteractiveShell NewShell(
        TestConsole console, RecentTargetsStore recents, params IInteractiveFlow[] flows) =>
        new(console, recents, new TargetConfigReader(), new SecretsSessionFactory(), flows);

    private RecentTargetsStore NewRecents(TimeProvider? clock = null) =>
        new(Path.Combine(_dir, "recents.json"), clock);

    /// <summary>Records what the shell dispatched to it without touching store logic.</summary>
    private sealed class FakeFlow(string title, bool requiresKek = false, Action<SecretsSession>? onRun = null)
        : IInteractiveFlow
    {
        public string Title { get; } = title;
        public bool RequiresKek { get; } = requiresKek;
        public int InvocationCount { get; private set; }
        public SecretsSession? LastSession { get; private set; }

        public Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
        {
            InvocationCount++;
            LastSession = session;
            onRun?.Invoke(session);
            return Task.CompletedTask;
        }
    }

    // --- Picker input helpers (SelectionPrompt: DownArrow to move, Enter to accept) ---

    private static void SelectIndex(TestConsole console, int index)
    {
        for (int i = 0; i < index; i++)
        {
            console.Input.PushKey(ConsoleKey.DownArrow);
        }

        console.Input.PushKey(ConsoleKey.Enter);
    }

    // Manual-entry target: with no recents the picker is [Enter appsettings(0), Manual entry(1)].
    private void ScriptManualEntry(TestConsole console, string envVarName)
    {
        SelectIndex(console, 1); // "Manual entry…"
        console.Input.PushTextWithEnter(DbPath);
        console.Input.PushTextWithEnter(envVarName);
    }

    [Fact]
    public async Task Shell_shows_target_picker_then_menu_and_quits()
    {
        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents());

        ScriptManualEntry(console, UnsetEnvVarName());
        // No flows: menu is [Switch store target(0), Quit(1)].
        SelectIndex(console, 1); // Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Contains("Manual entry", console.Output, StringComparison.Ordinal);
        Assert.Contains("appsettings", console.Output, StringComparison.Ordinal);
        Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
        Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Recents_appear_first_in_target_picker_and_selection_opens_session()
    {
        string envVar = SetTempKeyEnv();
        string appSettings = WriteAppSettings(envVar);

        var clock = new MutableClock(DateTimeOffset.Parse("2026-01-01T00:00:00Z"));
        RecentTargetsStore recents = NewRecents(clock);
        recents.Touch("prod-app", appSettings);
        DateTimeOffset original = recents.Load()[0].LastUsedUtc;

        clock.Advance(TimeSpan.FromHours(1));

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, recents);

        SelectIndex(console, 0);   // recent is first
        SelectIndex(console, 1);   // menu (no flows): Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Contains("prod-app", console.Output, StringComparison.Ordinal);

        IReadOnlyList<RecentTargetsStore.RecentTarget> after = recents.Load();
        Assert.Equal(appSettings, after[0].AppSettingsPath);       // still first
        Assert.True(after[0].LastUsedUtc > original);              // re-Touched with a newer stamp
    }

    [Fact]
    public async Task Menu_lists_registered_flows_by_title_plus_builtins()
    {
        var alpha = new FakeFlow("Alpha flow");
        var beta = new FakeFlow("Beta flow");

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), alpha, beta);

        ScriptManualEntry(console, UnsetEnvVarName());
        // Menu is [Alpha(0), Beta(1), Switch(2), Quit(3)].
        SelectIndex(console, 3); // Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Contains("Alpha flow", console.Output, StringComparison.Ordinal);
        Assert.Contains("Beta flow", console.Output, StringComparison.Ordinal);
        Assert.Contains("Switch store target", console.Output, StringComparison.Ordinal);
        Assert.Contains("Quit", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Degraded_session_flow_requiring_kek_prompts_for_key_first()
    {
        var flow = new FakeFlow("Seal something", requiresKek: true);
        string pastedKey = NewKeyBase64();

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), flow);

        ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
        // Menu is [Seal something(0), Switch(1), Quit(2)].
        SelectIndex(console, 0);       // choose the KEK-requiring flow
        SelectIndex(console, 0);       // key source: "Paste base64 key"
        console.Input.PushTextWithEnter(pastedKey);
        SelectIndex(console, 2);       // back at the menu → Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Equal(1, flow.InvocationCount);
        Assert.NotNull(flow.LastSession);
        Assert.True(flow.LastSession!.KekAvailable);              // session was upgraded before dispatch
        Assert.DoesNotContain(pastedKey, console.Output, StringComparison.Ordinal); // masked
    }

    [Fact]
    public async Task Flow_not_requiring_kek_runs_on_degraded_session()
    {
        var flow = new FakeFlow("List metadata", requiresKek: false);

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), flow);

        ScriptManualEntry(console, UnsetEnvVarName()); // degraded session
        SelectIndex(console, 0); // run the flow
        SelectIndex(console, 2); // menu [flow(0), Switch(1), Quit(2)] → Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Equal(1, flow.InvocationCount);
        Assert.NotNull(flow.LastSession);
        Assert.False(flow.LastSession!.KekAvailable); // ran on the degraded session
    }

    [Fact]
    public async Task Flow_exception_renders_error_panel_and_returns_to_menu()
    {
        var flow = new FakeFlow(
            "Boom",
            requiresKek: false,
            onRun: _ => throw new SecretDecryptionException("tag mismatch"));

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), flow);

        ScriptManualEntry(console, UnsetEnvVarName());
        SelectIndex(console, 0); // run the throwing flow
        SelectIndex(console, 2); // loop returned to menu → Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Equal(1, flow.InvocationCount);
        Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Flow_throwing_UnauthorizedAccess_is_contained_and_returns_to_menu()
    {
        var flow = new FakeFlow(
            "Read key file",
            requiresKek: false,
            onRun: _ => throw new UnauthorizedAccessException("permission denied"));

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), flow);

        ScriptManualEntry(console, UnsetEnvVarName());
        SelectIndex(console, 0); // run the throwing flow
        SelectIndex(console, 2); // loop returned to menu → Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
        Assert.Equal(1, flow.InvocationCount);
        Assert.Contains("KEK doctor", console.Output, StringComparison.Ordinal);
    }

    [Fact]
    public void Constructor_rejects_duplicate_flow_titles()
    {
        var first = new FakeFlow("Same title");
        var second = new FakeFlow("Same title");

        Assert.Throws<ArgumentException>(() => NewShell(NewConsole(), NewRecents(), first, second));
    }

    [Fact]
    public void Constructor_rejects_flow_title_colliding_with_a_builtin()
    {
        var reserved = new FakeFlow("Quit");

        Assert.Throws<ArgumentException>(() => NewShell(NewConsole(), NewRecents(), reserved));
    }

    [Fact]
    public async Task Cancelled_token_exits_menu_loop_cleanly_with_zero()
    {
        using var cts = new CancellationTokenSource();
        // The flow cancels the shell's own token mid-action; the menu loop's top-of-loop cancellation
        // check must then exit 0 without prompting for (unscripted) further input or throwing. This is
        // the testable stand-in for a menu-level Ctrl-C, which TestConsole cannot inject into a prompt.
        var flow = new FakeFlow("Cancel the shell", requiresKek: false, onRun: _ => cts.Cancel());

        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents(), flow);

        ScriptManualEntry(console, UnsetEnvVarName());
        SelectIndex(console, 0); // run the flow, which cancels the token — no more input is scripted

        int result = await shell.RunAsync(cts.Token);

        Assert.Equal(0, result);
        Assert.Equal(1, flow.InvocationCount);
    }

    [Fact]
    public async Task Switch_target_reopens_picker()
    {
        TestConsole console = NewConsole();
        InteractiveShell shell = NewShell(console, NewRecents());

        ScriptManualEntry(console, UnsetEnvVarName()); // first target
        SelectIndex(console, 0);                       // menu [Switch(0), Quit(1)] → Switch
        ScriptManualEntry(console, UnsetEnvVarName()); // picker reopened → second target
        SelectIndex(console, 1);                       // Quit

        int result = await shell.RunAsync(CancellationToken.None);

        Assert.Equal(0, result);
    }

    /// <summary>A hand-advanced clock so a re-Touch produces a strictly newer timestamp.</summary>
    private sealed class MutableClock(DateTimeOffset start) : TimeProvider
    {
        private DateTimeOffset _now = start;

        public void Advance(TimeSpan by) => _now += by;

        public override DateTimeOffset GetUtcNow() => _now;
    }
}
