using System.Globalization;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Rotation;

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

/// <summary>
/// The lockout-recovery flow. An operator whose app can no longer decrypt its secrets runs the doctor to
/// learn, per row, which KEK each row is wrapped under and whether the session KEK opens it (via
/// <see cref="KekDoctor.DiagnoseAsync"/>), then chooses a remedy for the wrong-KEK (and corrupt) rows:
/// <list type="bullet">
/// <item><b>Rewrap from old KEK</b> — the operator supplies the KEK the rows are currently wrapped under
/// (from an environment variable, a key file, or a pasted base64 key) and the flow re-wraps every row onto
/// the session KEK after an explicit, exactly-scoped confirmation. This is the non-destructive remedy: the
/// secret bodies are preserved, only the DEK wrap changes.</item>
/// <item><b>Old KEK is lost — re-set affected secrets</b> — when the old KEK is unrecoverable, each affected
/// row is offered a masked re-set (a fresh value sealed under the session KEK). This is the destructive
/// last resort and the only remedy for a <see cref="RowKekStatus.Corrupt"/> row (whose body cannot be
/// re-wrapped or preserved).</item>
/// </list>
/// Requires a KEK-capable session (the shell upgrades a degraded session before dispatch). No key material
/// or typed value is ever echoed: the pasted old KEK and re-set values use masked prompts, and every
/// operator-controlled string (secret names, KEK ids, error messages) is markup-escaped.
/// </summary>
public sealed class KekDoctorFlow : IInteractiveFlow
{
    private const string RewrapChoice = "Rewrap from old KEK";
    private const string LostKekChoice = "Old KEK is lost — re-set affected secrets";
    private const string BackChoice = "Back";

    private const string PasteSource = "Paste base64 key";
    private const string EnvSource = "Environment variable";
    private const string FileSource = "Key file";

    /// <inheritdoc />
    public string Title => "KEK doctor (lockout recovery)";

    /// <inheritdoc />
    public bool RequiresKek => true;

    /// <inheritdoc />
    public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(console);
        ArgumentNullException.ThrowIfNull(session);

        var doctor = new KekDoctor();

        // Diagnose is deliberately not wrapped: a degraded-session or store fault is the shell's to contain.
        KekDoctorReport report = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
        RenderSummary(console, report);

        if (report.Healthy)
        {
            console.MarkupLine("[green]All rows open under the session KEK — no lockout. Nothing to remedy.[/]");
            return;
        }

        string choice = console.Prompt(new SelectionPrompt<string>()
            .Title("Choose a [yellow]remedy[/]")
            .AddChoices(RewrapChoice, LostKekChoice, BackChoice));

        switch (choice)
        {
            case RewrapChoice:
                await RunRewrapAsync(console, session, doctor, report, ct).ConfigureAwait(false);
                break;
            case LostKekChoice:
                await RunLostKekResetAsync(console, session, report, ct).ConfigureAwait(false);
                break;
            default:
                console.MarkupLine("[grey]No changes made.[/]");
                return;
        }

        // Re-diagnose so the operator sees the store's post-remedy state in one closing line.
        KekDoctorReport after = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
        console.MarkupLineInterpolated($"Re-diagnosis: {Summarize(after)}.");
    }

    // Renders the session KEK, the row total, the per-status counts, and the distinct foreign kek_ids the
    // operator must hunt down. Every operator-controlled value rides an interpolated (auto-escaped) hole.
    private static void RenderSummary(IAnsiConsole console, KekDoctorReport report)
    {
        console.MarkupLineInterpolated(
            $"Session KEK [cyan]{report.SessionKekId}[/] — {report.Total} row(s) scanned.");

        int ok = Count(report, RowKekStatus.Ok);
        int wrong = Count(report, RowKekStatus.WrongKek);
        int corrupt = Count(report, RowKekStatus.Corrupt);
        console.MarkupLineInterpolated(
            $"[green]{ok} Ok[/], [yellow]{wrong} wrong-KEK[/], [red]{corrupt} corrupt[/].");

        IReadOnlyList<string> foreign = ForeignKekIds(report);
        if (foreign.Count > 0)
        {
            console.MarkupLineInterpolated($"Foreign KEK id(s) seen: {string.Join(", ", foreign)}.");
        }
    }

    // The guided rewrap remedy: acquire the old KEK provider, show EXACTLY what will change, and re-wrap
    // only after an explicit confirmation (defaulting false). The rewrap itself is the only guarded region —
    // a wrong pasted key surfaces as a SecretDecryptionException, an old==session KEK as an ArgumentException.
    private static async Task RunRewrapAsync(
        IAnsiConsole console, SecretsSession session, KekDoctor doctor, KekDoctorReport report, CancellationToken ct)
    {
        // Acquire the old KEK and resolve its id under their OWN guard: a malformed pasted key throws
        // ArgumentException from LiteralMasterKeyProvider, and an env/file source lazily throws
        // MasterKeyUnavailableException the moment KekId is read (an unset var, a missing file) — or, for
        // a file source, a raw IOException/UnauthorizedAccessException straight out of File.ReadAllBytes
        // (e.g. a permission-denied key file), since FileMasterKeyProvider does not wrap those. All must
        // render a friendly line and return here — not escape to the shell — so the closing re-diagnosis
        // in RunAsync still runs. This is distinct from the rewrap guard below (a wrong key vs a valid one).
        IMasterKeyProvider oldKek;
        string oldKekId;
        try
        {
            oldKek = PromptForOldKek(console);
            oldKekId = oldKek.KekId;
        }
        catch (ArgumentException ex)
        {
            console.MarkupLineInterpolated($"[red]Invalid old KEK:[/] {ex.Message}");
            return;
        }
        catch (Exception ex) when (ex is MasterKeyUnavailableException or IOException or UnauthorizedAccessException)
        {
            console.MarkupLineInterpolated($"[red]Old KEK unavailable:[/] {ex.Message}");
            return;
        }

        // Confirm OUTSIDE the rewrap guard: only the rewrap call itself is caught there, so a stray
        // ArgumentException from setup/prompt code is never mis-reported as a "rewrap failed" message.
        int moving = report.Rows.Count(r => string.Equals(r.RowKekId, oldKekId, StringComparison.Ordinal));

        string confirmText =
            $"Re-wrap {moving} row(s) from KEK '{Markup.Escape(oldKekId)}' → session KEK " +
            $"'{Markup.Escape(report.SessionKekId)}'? (rewraps every row on the old KEK)";
        if (!console.Prompt(new ConfirmationPrompt(confirmText) { DefaultValue = false }))
        {
            console.MarkupLine("[grey]Rewrap cancelled — no rows changed.[/]");
            return;
        }

        RewrapReport result;
        try
        {
            result = await doctor.RewrapAllAsync(session, oldKek, ct).ConfigureAwait(false);
        }
        catch (ArgumentException ex)
        {
            // Old and session KEK are identical — nothing to rotate.
            console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
            return;
        }
        catch (SecretDecryptionException ex)
        {
            // Wrong old key, or a row wrapped by neither the old nor the session KEK (the pass aborted;
            // any rows re-wrapped before the anomaly stay persisted).
            console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
            return;
        }

        console.MarkupLineInterpolated(
            $"[green]Re-wrapped {result.Rewrapped} row(s)[/] onto the session KEK ({result.AlreadyCurrent} already current) of {result.Total} total.");
    }

    // Prompts for the old-KEK source and builds the matching provider. Paste feeds a masked base64 entry to
    // the LiteralMasterKeyProvider; env/file defer to the shared MasterKeyProviderFactory.
    private static IMasterKeyProvider PromptForOldKek(IAnsiConsole console)
    {
        string source = console.Prompt(new SelectionPrompt<string>()
            .Title("Where is the [yellow]old KEK[/]?")
            .AddChoices(EnvSource, FileSource, PasteSource));

        switch (source)
        {
            case EnvSource:
                string envVar = console.Prompt(new TextPrompt<string>("  Environment variable name:"));
                return MasterKeyProviderFactory.Create(new MasterKeyOptions
                {
                    Source = MasterKeySource.Environment,
                    EnvVarName = envVar,
                });
            case FileSource:
                string path = console.Prompt(new TextPrompt<string>("  Key file path:"));
                return MasterKeyProviderFactory.Create(new MasterKeyOptions
                {
                    Source = MasterKeySource.File,
                    FilePath = path,
                });
            default:
                string base64 = console.Prompt(
                    new TextPrompt<string>("  Paste the base64 old KEK:").Secret());
                return new LiteralMasterKeyProvider(base64);
        }
    }

    // The destructive last resort: for every wrong-KEK or corrupt row, offer a masked re-set of a fresh
    // value sealed under the session KEK. A corrupt row is flagged first (re-set is its only remedy).
    private static async Task RunLostKekResetAsync(
        IAnsiConsole console, SecretsSession session, KekDoctorReport report, CancellationToken ct)
    {
        IReadOnlyList<KekDiagnosis> affected = report.Rows
            .Where(r => r.Status is RowKekStatus.WrongKek or RowKekStatus.Corrupt)
            .ToList();

        if (affected.Count == 0)
        {
            console.MarkupLine("[grey]No wrong-KEK or corrupt rows to re-set.[/]");
            return;
        }

        foreach (KekDiagnosis row in affected)
        {
            string escapedName = Markup.Escape(row.SecretName);

            if (row.Status == RowKekStatus.Corrupt)
            {
                console.MarkupLineInterpolated(
                    $"[red]'{row.SecretName}' is corrupt under the session KEK — it cannot be re-wrapped or preserved; re-setting a fresh value is the only remedy.[/]");
            }

            if (!console.Prompt(new ConfirmationPrompt(
                    $"Re-set '{escapedName}' with a new value? (overwrites)") { DefaultValue = false }))
            {
                continue;
            }

            await ResetAsync(console, session, new SecretName(row.SecretName), ct).ConfigureAwait(false);
            console.MarkupLineInterpolated($"[green]Re-set '{row.SecretName}' under the session KEK.[/]");
        }
    }

    // Prompts for a masked value + content type and seals a fresh row under the session cipher, overwriting
    // the wrong-KEK/corrupt row in place. Mirrors the seed-and-stamp shape used by the other flows.
    private static async Task ResetAsync(
        IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct)
    {
        string value = console.Prompt(
            new TextPrompt<string>($"  New value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
        SecretContentType contentType = console.Prompt(
            new SelectionPrompt<SecretContentType>().Title("  Content type").AddChoices(Enum.GetValues<SecretContentType>()));

        ISecretCipher cipher = session.Cipher
            ?? throw new InvalidOperationException("flow requires an unlocked KEK session");
        StoredSecret row = cipher.Encrypt(name, value, contentType) with
        {
            CreatedBy = FlowPrompts.Actor,
            UpdatedBy = FlowPrompts.Actor,
        };
        await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
    }

    private static int Count(KekDoctorReport report, RowKekStatus status) =>
        report.Rows.Count(r => r.Status == status);

    // The distinct foreign kek_ids (wrong-KEK rows only), ordered for a deterministic render.
    private static IReadOnlyList<string> ForeignKekIds(KekDoctorReport report) =>
        report.Rows
            .Where(r => r.Status == RowKekStatus.WrongKek)
            .Select(r => r.RowKekId)
            .Distinct(StringComparer.Ordinal)
            .OrderBy(id => id, StringComparer.Ordinal)
            .ToList();

    // A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
    private static string Summarize(KekDoctorReport report) =>
        string.Join(", ", report.Rows
            .GroupBy(r => r.Status)
            .OrderBy(g => g.Key)
            .Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
}
