using System.Data.Common;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;

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

/// <summary>
/// The interactive <c>secret</c> console: pick a store target, open a session, then loop a header +
/// action menu that dispatches to pluggable <see cref="IInteractiveFlow"/>s. This is the ONLY class
/// that touches <see cref="IAnsiConsole"/> — services stay UI-free and flows, though UI-owning, carry
/// no store logic. Flow failures are contained (rendered as an error panel) so one bad action never
/// tears down the session; a degraded (no-KEK) session is upgraded on demand before a KEK-requiring
/// flow runs.
/// </summary>
public sealed class InteractiveShell
{
    private const string EnterPathLabel = "Enter an appsettings.json path…";
    private const string ManualEntryLabel = "Manual entry (SQLite db + KEK env var)…";
    private const string SwitchLabel = "Switch store target";
    private const string QuitLabel = "Quit";
    private const string PasteKeyLabel = "Paste base64 key";
    private const string KeyFileLabel = "Key file path";
    private const string CancelLabel = "Cancel";

    private readonly IAnsiConsole _console;
    private readonly RecentTargetsStore _recents;
    private readonly TargetConfigReader _reader;
    private readonly SecretsSessionFactory _sessions;
    private readonly IReadOnlyList<IInteractiveFlow> _flows;

    /// <summary>Creates the shell over its console, recents store, target reader, session factory, and flow set.</summary>
    /// <param name="console">The single console surface the shell (and its flows) render to.</param>
    /// <param name="recents">Recently-used target store, offered first in the picker and re-stamped on selection.</param>
    /// <param name="reader">Composes a target's Secrets configuration from appsettings or manual input.</param>
    /// <param name="sessions">Opens (and, on KEK upgrade, re-opens) a <see cref="SecretsSession"/> for a target.</param>
    /// <param name="flows">The pluggable menu actions, listed in order above the built-in switch/quit entries.</param>
    public InteractiveShell(
        IAnsiConsole console,
        RecentTargetsStore recents,
        TargetConfigReader reader,
        SecretsSessionFactory sessions,
        IReadOnlyList<IInteractiveFlow> flows)
    {
        ArgumentNullException.ThrowIfNull(console);
        ArgumentNullException.ThrowIfNull(recents);
        ArgumentNullException.ThrowIfNull(reader);
        ArgumentNullException.ThrowIfNull(sessions);
        ArgumentNullException.ThrowIfNull(flows);

        // A flow's Title is the menu routing key (BuildMenu emits it; the loop dispatches with a
        // .First() title match), so a duplicate — or a collision with a reserved built-in label —
        // would silently misroute selections. Reject both at construction rather than at click time.
        HashSet<string> seenTitles = new(StringComparer.Ordinal);
        foreach (IInteractiveFlow flow in flows)
        {
            if (flow.Title is SwitchLabel or QuitLabel)
            {
                throw new ArgumentException(
                    $"Flow title '{flow.Title}' collides with a reserved menu action.", nameof(flows));
            }

            if (!seenTitles.Add(flow.Title))
            {
                throw new ArgumentException($"Duplicate flow title '{flow.Title}'.", nameof(flows));
            }
        }

        _console = console;
        _recents = recents;
        _reader = reader;
        _sessions = sessions;
        _flows = flows;
    }

    /// <summary>Runs the pick-target → menu loop until the operator quits.</summary>
    /// <param name="ct">A token to cancel session opens and flow dispatch.</param>
    /// <returns><c>0</c> on a clean quit.</returns>
    public async Task<int> RunAsync(CancellationToken ct)
    {
        try
        {
            while (true)
            {
                if (ct.IsCancellationRequested)
                {
                    return 0;
                }

                StoreTarget? target = PickTarget();
                if (target is null)
                {
                    return 0; // operator cancelled the picker (Ctrl-C) → exit
                }

                SecretsSession session = await _sessions.OpenAsync(target, overrideKek: null, ct).ConfigureAwait(false);
                RenderWarnings(session);

                if (await RunMenuLoopAsync(session, ct).ConfigureAwait(false) == MenuOutcome.Quit)
                {
                    return 0;
                }

                // MenuOutcome.SwitchTarget → fall through and re-open the picker.
            }
        }
        catch (OperationCanceledException)
        {
            // Cooperative cancellation of the shell's token (Ctrl-C, or a flow/host cancelling it) is a
            // clean shutdown, not an error — surfaced e.g. from a pre-cancelled session open.
            return 0;
        }
    }

    // The outcome of the inner menu loop: the operator either quit outright or asked to switch targets.
    private enum MenuOutcome
    {
        Quit,
        SwitchTarget,
    }

    private async Task<MenuOutcome> RunMenuLoopAsync(SecretsSession session, CancellationToken ct)
    {
        while (true)
        {
            // Honor cooperative cancellation between actions. Spectre's blocking prompt cannot observe
            // the token itself (TestConsole likewise cannot inject a Ctrl-C into a prompt), so this
            // top-of-loop check is what turns a token cancelled during a flow into a clean exit.
            if (ct.IsCancellationRequested)
            {
                return MenuOutcome.Quit;
            }

            RenderHeader(session);

            string choice;
            try
            {
                choice = _console.Prompt(BuildMenu());
            }
            catch (OperationCanceledException)
            {
                return MenuOutcome.Quit; // Ctrl-C at the menu → clean exit
            }

            switch (choice)
            {
                case QuitLabel:
                    return MenuOutcome.Quit;
                case SwitchLabel:
                    return MenuOutcome.SwitchTarget;
            }

            IInteractiveFlow flow = _flows.First(f => f.Title == choice);
            try
            {
                if (flow.RequiresKek && !session.KekAvailable)
                {
                    SecretsSession? upgraded = await TryUpgradeSessionAsync(session, ct).ConfigureAwait(false);
                    if (upgraded is null)
                    {
                        continue; // cancelled or key rejected → back to the menu
                    }

                    session = upgraded; // keep the upgraded session for the rest of the loop
                }

                await flow.RunAsync(_console, session, ct).ConfigureAwait(false);
            }
            catch (OperationCanceledException)
            {
                // Ctrl-C mid-flow → abandon the action and return to the menu.
            }
            catch (Exception ex) when (IsContainable(ex))
            {
                RenderErrorPanel(ex);
            }
        }
    }

    // The store/key faults a flow may realistically surface. SecretDecryptionException and
    // MasterKeyUnavailableException both derive from InvalidOperationException; DbException (base of both
    // Microsoft.Data.Sqlite and Microsoft.Data.SqlClient exceptions) covers store-provider faults;
    // UnauthorizedAccessException (a permission-denied key file or appsettings — NOT an IOException
    // subtype) is exactly the lockout path this console exists to recover, so it must be contained too.
    // A blanket catch is deliberately avoided so genuinely unexpected faults still surface.
    private static bool IsContainable(Exception ex) =>
        ex is InvalidOperationException or IOException or ArgumentException or DbException
            or UnauthorizedAccessException;

    // Presents the target picker: recents newest-first, then the two entry options. Returns the resolved
    // target, or null if the operator cancelled the selection (Ctrl-C). Re-prompts on a recoverable
    // read error so a bad path does not drop the operator out of the console.
    private StoreTarget? PickTarget()
    {
        while (true)
        {
            IReadOnlyList<RecentTargetsStore.RecentTarget> recents = _recents.Load();

            List<string> choices = [];
            Dictionary<string, RecentTargetsStore.RecentTarget> byLabel = new(StringComparer.Ordinal);
            foreach (RecentTargetsStore.RecentTarget recent in recents)
            {
                string label = $"{recent.Name} — {recent.AppSettingsPath}";
                if (byLabel.ContainsKey(label))
                {
                    continue; // Load() already de-dups by path, but guard against colliding labels.
                }

                choices.Add(label);
                byLabel[label] = recent;
            }

            choices.Add(EnterPathLabel);
            choices.Add(ManualEntryLabel);

            string picked;
            try
            {
                picked = _console.Prompt(new SelectionPrompt<string>()
                    .Title("Select a store target")
                    .AddChoices(choices));
            }
            catch (OperationCanceledException)
            {
                return null;
            }

            try
            {
                if (picked == ManualEntryLabel)
                {
                    return ReadManualTarget();
                }

                string appSettingsPath = picked == EnterPathLabel
                    ? _console.Prompt(new TextPrompt<string>("appsettings.json path:"))
                    : byLabel[picked].AppSettingsPath;

                StoreTarget target = _reader.Read(appSettingsPath);
                _recents.Touch(target.Name, target.AppSettingsPath!); // Read targets always carry a path
                return target;
            }
            catch (OperationCanceledException)
            {
                return null;
            }
            catch (Exception ex) when (IsContainable(ex))
            {
                RenderErrorPanel(ex); // e.g. FileNotFoundException — show it and re-open the picker
            }
        }
    }

    private StoreTarget ReadManualTarget()
    {
        string dbPath = _console.Prompt(new TextPrompt<string>("SQLite database path:"));
        string envVar = _console.Prompt(new TextPrompt<string>("Master-key environment variable name:")
            .DefaultValue("ZB_SECRETS_MASTER_KEY"));

        // Manual targets have no appsettings.json, so they are not remembered as recents.
        return _reader.Manual(dbPath, new MasterKeyOptions
        {
            Source = MasterKeySource.Environment,
            EnvVarName = envVar,
        });
    }

    // Prompts the operator for a KEK to lift a degraded session, re-opening it with the supplied key.
    // Returns the upgraded (KEK-capable) session, or null if the operator cancelled or the key was
    // rejected — in which case the caller stays on the menu.
    private async Task<SecretsSession?> TryUpgradeSessionAsync(SecretsSession session, CancellationToken ct)
    {
        _console.MarkupLine("[yellow]This action needs a KEK, but the session is degraded.[/]");

        string source;
        try
        {
            source = _console.Prompt(new SelectionPrompt<string>()
                .Title("Provide the key")
                .AddChoices(PasteKeyLabel, KeyFileLabel, CancelLabel));
        }
        catch (OperationCanceledException)
        {
            return null;
        }

        if (source == CancelLabel)
        {
            return null;
        }

        IMasterKeyProvider provider;
        try
        {
            if (source == PasteKeyLabel)
            {
                string base64 = _console.Prompt(new TextPrompt<string>("Paste base64 KEK:").Secret());
                provider = new LiteralMasterKeyProvider(base64);
            }
            else
            {
                string filePath = _console.Prompt(new TextPrompt<string>("Key file path:"));
                provider = MasterKeyProviderFactory.Create(new MasterKeyOptions
                {
                    Source = MasterKeySource.File,
                    FilePath = filePath,
                });
            }
        }
        catch (ArgumentException ex)
        {
            // LiteralMasterKeyProvider rejects a bad paste eagerly; name the problem and stay on the menu.
            _console.MarkupLineInterpolated($"[red]Invalid key: {ex.Message}[/]");
            return null;
        }

        SecretsSession upgraded = await _sessions.OpenAsync(session.Target, provider, ct).ConfigureAwait(false);
        if (!upgraded.KekAvailable)
        {
            RenderWarnings(upgraded);
            _console.MarkupLine("[red]That key did not unlock the session.[/]");
            return null;
        }

        return upgraded;
    }

    private SelectionPrompt<string> BuildMenu()
    {
        List<string> actions = [];
        actions.AddRange(_flows.Select(f => f.Title));
        actions.Add(SwitchLabel);
        actions.Add(QuitLabel);

        return new SelectionPrompt<string>()
            .Title("Action")
            .AddChoices(actions);
    }

    private void RenderHeader(SecretsSession session)
    {
        string kek = session.KekAvailable
            ? "[green]KEK OK[/]"
            : "[red]KEK UNAVAILABLE (degraded)[/]";

        Markup body = new(
            $"[bold]{Markup.Escape(session.Target.Name)}[/]    " +
            $"store: {Markup.Escape(session.StoreKind)}    {kek}");

        _console.Write(new Panel(body).Header("Target"));
    }

    private void RenderWarnings(SecretsSession session)
    {
        foreach (string warning in session.Warnings)
        {
            _console.MarkupLineInterpolated($"[yellow]{warning}[/]");
        }
    }

    private void RenderErrorPanel(Exception ex)
    {
        Markup body = new(
            $"[red]{Markup.Escape(ex.Message)}[/]\n\nIf this is a key problem, run the KEK doctor.");

        _console.Write(new Panel(body)
            .Header("[red]Action failed[/]")
            .BorderColor(Color.Red));
    }
}
