using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;

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

/// <summary>The resolvability of a single <c>${secret:NAME}</c> reference against the target's store.</summary>
public enum ReferenceStatus
{
    /// <summary>The referenced secret exists and decrypts under the session's KEK.</summary>
    Ok,

    /// <summary>
    /// The referenced secret exists, but the session is degraded (no KEK) so decryptability could not
    /// be verified — presence is all that could be established.
    /// </summary>
    PresentUnverified,

    /// <summary>No row exists for the referenced secret — a fail-closed expansion would throw at startup.</summary>
    Missing,

    /// <summary>A row exists but is soft-deleted (tombstoned) — treated as absent by the resolver.</summary>
    Tombstoned,

    /// <summary>The row exists but does not decrypt under the session's KEK (wrong or rotated KEK, or tampering).</summary>
    Undecryptable,

    /// <summary>
    /// The token names a value that is not a valid <see cref="SecretName"/> (illegal characters, an
    /// unfilled placeholder like <c>REPLACE ME</c>, and so on), so it cannot be looked up at all.
    /// </summary>
    InvalidName,
}

/// <summary>
/// One audited secret reference: the referenced name, its resolvability <see cref="Status"/>, and every
/// configuration key path that references it.
/// </summary>
/// <param name="SecretName">The referenced secret name, exactly as it appears in the token (trimmed).</param>
/// <param name="Status">The reference's resolvability against the target's store.</param>
/// <param name="ConfigPaths">The configuration key paths that reference this name, sorted (ordinal).</param>
public sealed record ReferenceFinding(
    string SecretName, ReferenceStatus Status, IReadOnlyList<string> ConfigPaths);

/// <summary>Scans a target app's composed configuration for <c>${secret:NAME}</c> tokens and checks each against the store.</summary>
public sealed class ReferenceAuditor
{
    // Pinned to SecretReferenceExpander.TokenPattern — MUST stay identical so the auditor sees exactly
    // the references the expander would resolve at startup. Do NOT diverge from the expander's regex.
    private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);

    /// <summary>
    /// Walks every configuration value, collects each distinct <c>${secret:NAME}</c> reference with the
    /// config key paths that use it, and classifies each name against the session's store. References are
    /// keyed by their <em>normalized</em> <see cref="SecretName.Value"/> so case/whitespace variants of one
    /// secret collapse to a single finding whose name matches what a <c>get</c> would resolve; a token that
    /// is not a valid name is reported once as <see cref="ReferenceStatus.InvalidName"/> under its raw text
    /// rather than aborting the whole audit. Findings are ordered by name (ordinal); each finding's config
    /// paths are likewise sorted and de-duplicated.
    /// </summary>
    /// <param name="session">The open store session whose target configuration is audited.</param>
    /// <param name="ct">A token to cancel the store lookups.</param>
    /// <returns>One finding per distinct referenced secret.</returns>
    public async Task<IReadOnlyList<ReferenceFinding>> AuditAsync(SecretsSession session, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(session);

        // group key -> collected reference. The key is the normalized SecretName.Value for a valid name
        // (so variants collapse), or the raw trimmed token text for an invalid one. SortedDictionary keeps
        // findings ordered by name; each group's SortedSet keeps its paths deterministic.
        var groups = new SortedDictionary<string, ReferenceGroup>(StringComparer.Ordinal);
        foreach (KeyValuePair<string, string?> entry in session.Target.Configuration.AsEnumerable())
        {
            // Mirror SecretReferenceExpander.IsCommentKey: an entry whose leaf key segment starts with '_'
            // (the "_comment" convention) may legitimately document the ${secret:...} syntax with a literal
            // example token and is never resolved — so the auditor must not report it either.
            if (entry.Value is not { } value || IsCommentKey(entry.Key))
            {
                continue;
            }

            foreach (Match match in TokenPattern.Matches(value))
            {
                string raw = match.Groups[1].Value.Trim();
                ReferenceGroup group = ResolveGroup(groups, raw);
                group.Paths.Add(entry.Key);
            }
        }

        var findings = new List<ReferenceFinding>(groups.Count);
        foreach ((string key, ReferenceGroup group) in groups)
        {
            ReferenceStatus status = group.Name is { } name
                ? await ClassifyAsync(session, name, ct).ConfigureAwait(false)
                : ReferenceStatus.InvalidName;
            findings.Add(new ReferenceFinding(key, status, [.. group.Paths]));
        }

        return findings;
    }

    // Finds (or creates) the group a raw token belongs to. A valid name groups under its normalized value
    // and carries the parsed SecretName; an invalid token (illegal chars, an unfilled placeholder, ...)
    // groups under its raw text with no name, so one bad token becomes one InvalidName finding instead of
    // throwing and aborting the entire audit.
    private static ReferenceGroup ResolveGroup(
        SortedDictionary<string, ReferenceGroup> groups, string raw)
    {
        string key;
        SecretName? name;
        try
        {
            var parsed = new SecretName(raw);
            key = parsed.Value;
            name = parsed;
        }
        catch (ArgumentException)
        {
            key = raw;
            name = null;
        }

        if (!groups.TryGetValue(key, out ReferenceGroup? group))
        {
            group = new ReferenceGroup(name);
            groups[key] = group;
        }

        return group;
    }

    // A configuration key is a comment/documentation key when its leaf segment (after the last ':') starts
    // with '_'. Duplicated from SecretReferenceExpander.IsCommentKey — keep the two in lockstep.
    private static bool IsCommentKey(string key)
    {
        int lastSeparator = key.LastIndexOf(':');
        string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key;
        return leaf.StartsWith('_');
    }

    // Present ⇒ tombstone-check ⇒ (degraded ⇒ presence-only) ⇒ decrypt-probe. The decrypted plaintext is
    // discarded immediately (never stored in a finding); only the success/failure signal is kept.
    private static async Task<ReferenceStatus> ClassifyAsync(
        SecretsSession session, SecretName name, CancellationToken ct)
    {
        StoredSecret? row = await session.Store.GetAsync(name, ct).ConfigureAwait(false);
        if (row is null)
        {
            return ReferenceStatus.Missing;
        }

        if (row.IsDeleted)
        {
            return ReferenceStatus.Tombstoned;
        }

        if (session.Cipher is not { } cipher)
        {
            return ReferenceStatus.PresentUnverified;
        }

        try
        {
            _ = cipher.Decrypt(row);
            return ReferenceStatus.Ok;
        }
        catch (SecretDecryptionException)
        {
            return ReferenceStatus.Undecryptable;
        }
    }

    // A collated reference: the config key paths that use it, plus the parsed name (null when the token is
    // not a valid SecretName — an InvalidName finding).
    private sealed class ReferenceGroup(SecretName? name)
    {
        public SortedSet<string> Paths { get; } = new(StringComparer.Ordinal);

        public SecretName? Name { get; } = name;
    }
}
