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

namespace ZB.MOM.WW.Secrets.Configuration;

/// <summary>
/// Expands <c>${secret:name}</c> reference tokens in configuration values by resolving each
/// referenced secret through an <see cref="ISecretResolver"/>. Expansion is <em>fail-closed</em>:
/// a token that names an absent secret throws <see cref="SecretNotFoundException"/> rather than
/// leaving the placeholder (or a blank) in place, so a misconfigured deployment fails loudly at
/// startup instead of silently running with an empty credential.
/// </summary>
public sealed class SecretReferenceExpander
{
    private const string TokenMarker = "${secret:";

    private static readonly Regex TokenPattern = new(@"\$\{secret:([^}]+)\}", RegexOptions.Compiled);

    private readonly ISecretResolver _resolver;

    /// <summary>Creates an expander that resolves references via <paramref name="resolver"/>.</summary>
    /// <param name="resolver">The resolver used to fetch each referenced secret's plaintext.</param>
    public SecretReferenceExpander(ISecretResolver resolver) =>
        _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));

    /// <summary>
    /// Replaces every <c>${secret:name}</c> token in <paramref name="raw"/> with the resolved
    /// plaintext of the named secret. Multiple tokens (including repeats of the same name) are all
    /// expanded; each distinct name is resolved once.
    /// </summary>
    /// <param name="raw">The raw configuration value, possibly containing reference tokens.</param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <returns>
    /// The expanded value, or <paramref name="raw"/> unchanged when it is <c>null</c> or contains
    /// no tokens.
    /// </returns>
    /// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
    public async Task<string> ExpandAsync(string raw, CancellationToken ct)
    {
        if (raw is null)
        {
            return raw!;
        }

        MatchCollection matches = TokenPattern.Matches(raw);
        if (matches.Count == 0)
        {
            return raw;
        }

        // Resolve each distinct referenced name exactly once, then substitute synchronously.
        var resolved = new Dictionary<string, string>(StringComparer.Ordinal);
        foreach (Match match in matches)
        {
            string name = match.Groups[1].Value.Trim();
            if (resolved.ContainsKey(name))
            {
                continue;
            }

            string? value = await _resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
            if (value is null)
            {
                throw new SecretNotFoundException(
                    $"Configuration references unknown secret '{name}' via ${{secret:{name}}}.");
            }

            resolved[name] = value;
        }

        return TokenPattern.Replace(raw, m => resolved[m.Groups[1].Value.Trim()]);
    }

    /// <summary>
    /// Walks every entry in <paramref name="config"/> and rewrites, in place, any value that
    /// contains a <c>${secret:...}</c> token to its expanded plaintext. Writes go through the
    /// <see cref="IConfigurationRoot"/> indexer so subsequent reads — and any options binding done
    /// afterward — observe the expanded values.
    /// </summary>
    /// <remarks>
    /// This MUST run <em>after</em> configuration is loaded and <em>before</em> options validation
    /// or <c>ValidateOnStart</c> executes; otherwise validators may see the unexpanded placeholder.
    /// Fail-closed: a missing referenced secret propagates <see cref="SecretNotFoundException"/> out
    /// of startup.
    /// <para>
    /// Documentation/comment keys are skipped: any entry whose leaf key segment (the part after the
    /// last <c>:</c>) begins with an underscore — the widely-used <c>"_comment"</c> JSON convention —
    /// is left untouched. This lets an appsettings file document the <c>${secret:...}</c> syntax in a
    /// <c>_comment</c> value (even with a literal example token) without that example being treated as
    /// a real reference to resolve. Functional keys are never named with a leading underscore.
    /// </para>
    /// </remarks>
    /// <param name="config">The loaded configuration root to mutate.</param>
    /// <param name="ct">A token to cancel the operation.</param>
    /// <exception cref="SecretNotFoundException">A referenced secret does not exist (fail-closed).</exception>
    public async Task ExpandConfigurationAsync(IConfigurationRoot config, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(config);

        // Materialize first: mutating provider data while enumerating AsEnumerable() is unsafe.
        var pending = new List<KeyValuePair<string, string>>();
        foreach (KeyValuePair<string, string?> entry in config.AsEnumerable())
        {
            if (entry.Value is { } value
                && value.Contains(TokenMarker, StringComparison.Ordinal)
                && !IsCommentKey(entry.Key))
            {
                pending.Add(new KeyValuePair<string, string>(entry.Key, value));
            }
        }

        foreach (KeyValuePair<string, string> entry in pending)
        {
            config[entry.Key] = await ExpandAsync(entry.Value, ct).ConfigureAwait(false);
        }
    }

    // A configuration key is a comment/documentation key when its leaf segment (after the last ':')
    // starts with '_' — the conventional "_comment" marker. Such values may legitimately contain a
    // literal ${secret:...} example and must not be resolved.
    private static bool IsCommentKey(string key)
    {
        int lastSeparator = key.LastIndexOf(':');
        string leaf = lastSeparator >= 0 ? key[(lastSeparator + 1)..] : key;
        return leaf.StartsWith('_');
    }
}
