using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Payload;

/// <summary>
/// Pure, stateless redaction + truncation primitives used by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// (which operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> + its <c>DetailsJson</c>).
/// Extracted in a ScadaBridge audit re-architecture so the
/// byte-exact redaction logic lives in ONE place.
/// </summary>
/// <remarks>
/// <para>
/// Each stage method is a pure function of its inputs (no instance state). The
/// only side effects are diagnostics-only: a warning log line and an
/// <paramref name="onFailure"/> callback invocation when a redactor faults, so
/// the caller can bump its redaction-failure health counter. The callbacks are
/// passed in (rather than the counter interface) to keep this helper free of
/// any DI / health-metric coupling.
/// </para>
/// <para>
/// The regex CACHE and per-call options resolution live in
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRegexCache"/> /
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/>
/// — they carry per-instance state (lazy compile, 100 ms compile budget,
/// sentinel entries). This helper only holds the stateless stages that
/// operate once the compiled regex set / redact list / cap has already been
/// resolved.
/// </para>
/// </remarks>
internal static class AuditRedactionPrimitives
{
    /// <summary>Marker replacing redacted header values, body matches, and SQL parameter values.</summary>
    public const string RedactedMarker = "<redacted>";

    /// <summary>Over-redaction marker emitted when a redactor stage itself faults.</summary>
    public const string RedactorErrorMarker = "<redacted: redactor error>";

    /// <summary>
    /// Marker used by the outer never-throws safety net when the entire redaction
    /// pipeline fails catastrophically — all potentially-sensitive string fields are
    /// set to this value so no raw payload leaks on an unexpected fault.
    /// Deliberately equal to <see cref="RedactorErrorMarker"/>: both represent a
    /// defensive scrub-everything fallback.
    /// </summary>
    public const string OverRedactedEventMarker = RedactorErrorMarker;

    /// <summary>
    /// JSON serializer options used to re-emit redacted summaries. The
    /// UnsafeRelaxedJsonEscaping encoder is required so the redaction marker
    /// (which contains <c>&lt;</c> / <c>&gt;</c>) survives unescaped — matching
    /// the legacy filter's output byte-for-byte.
    /// </summary>
    public static readonly JsonSerializerOptions RedactedSummaryJsonOptions = new()
    {
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
    };

    /// <summary>
    /// Parse <paramref name="json"/> as the documented
    /// <c>{"headers": {...}, "body": ...}</c> shape and replace values whose
    /// header NAME (case-insensitive) is in <paramref name="redactList"/> with
    /// <see cref="RedactedMarker"/>. Re-serialises and returns the result.
    /// No-op pass-through for inputs that are not JSON-object-shaped or do not
    /// carry a top-level <c>headers</c> object. On any unexpected fault the
    /// field is over-redacted with <see cref="RedactorErrorMarker"/> and
    /// <paramref name="onFailure"/> is invoked.
    /// </summary>
    /// <param name="json">The raw JSON string to redact; null passes through as null.</param>
    /// <param name="redactList">Header names (case-insensitive) whose values should be replaced.</param>
    /// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
    /// <param name="onFailure">Callback invoked when the redactor stage faults; used to increment health counters.</param>
    /// <returns>The re-serialized JSON with redacted header values, the original string if nothing was redacted, or <see cref="RedactorErrorMarker"/> on fault.</returns>
    public static string? RedactHeaders(
        string? json,
        IList<string> redactList,
        ILogger logger,
        Action onFailure)
    {
        if (json is null)
        {
            return null;
        }

        // Cheap structural pre-check: only attempt JSON parsing when the input
        // actually looks like a JSON object. Saves the JsonDocument allocation
        // on the (very common) non-JSON ErrorDetail / Extra fields.
        var trimmed = json.AsSpan().TrimStart();
        if (trimmed.Length == 0 || trimmed[0] != '{')
        {
            return json;
        }

        try
        {
            JsonNode? root;
            try
            {
                root = JsonNode.Parse(json);
            }
            catch (JsonException)
            {
                // Not parseable JSON — leave the field alone (no error, no
                // redaction). Emitters not yet using the documented shape get
                // a transparent pass.
                return json;
            }

            if (root is not JsonObject obj || obj["headers"] is not JsonObject headers)
            {
                // No "headers" object at the top level — nothing to redact.
                return json;
            }

            // Build a case-insensitive lookup of the redact list so we can do
            // one O(1) check per header name without an inner Any() loop.
            var redactSet = new HashSet<string>(redactList, StringComparer.OrdinalIgnoreCase);

            // Take a snapshot of names first — we cannot mutate while
            // enumerating the JsonObject.
            var names = new List<string>(headers.Count);
            foreach (var kvp in headers)
            {
                names.Add(kvp.Key);
            }
            foreach (var name in names)
            {
                if (redactSet.Contains(name))
                {
                    headers[name] = JsonValue.Create(RedactedMarker);
                }
            }

            return obj.ToJsonString(RedactedSummaryJsonOptions);
        }
        catch (Exception ex)
        {
            logger.LogWarning(
                ex,
                "Header redactor faulted; over-redacting field with '{Marker}'",
                RedactorErrorMarker);
            try { onFailure(); } catch { /* swallow per §7 */ }
            return RedactorErrorMarker;
        }
    }

    /// <summary>
    /// Apply each compiled body-redactor regex to <paramref name="value"/> in
    /// turn, replacing every match with <see cref="RedactedMarker"/>. If any
    /// single regex match throws (most commonly
    /// <see cref="RegexMatchTimeoutException"/>) the field is over-redacted
    /// with <see cref="RedactorErrorMarker"/> and <paramref name="onFailure"/>
    /// is invoked — the user-facing action is never aborted.
    /// </summary>
    /// <param name="value">The string to redact; null passes through as null.</param>
    /// <param name="regexes">Compiled body-redaction regexes applied in order.</param>
    /// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
    /// <param name="onFailure">Callback invoked when a regex match faults; used to increment health counters.</param>
    /// <returns>The value with all regex matches replaced by <see cref="RedactedMarker"/>, or <see cref="RedactorErrorMarker"/> on fault.</returns>
    public static string? RedactBody(
        string? value,
        IReadOnlyList<Regex> regexes,
        ILogger logger,
        Action onFailure)
    {
        if (value is null)
        {
            return null;
        }
        var current = value;
        foreach (var rx in regexes)
        {
            try
            {
                current = rx.Replace(current, RedactedMarker);
            }
            catch (Exception ex)
            {
                logger.LogWarning(
                    ex,
                    "Body redactor '{Pattern}' faulted; over-redacting field with '{Marker}'",
                    rx.ToString(), RedactorErrorMarker);
                try { onFailure(); } catch { /* swallow per §7 */ }
                return RedactorErrorMarker;
            }
        }
        return current;
    }

    /// <summary>
    /// Walk the <c>{"sql":"...","parameters":{...}}</c> RequestSummary
    /// shape; for each parameter whose NAME matches
    /// <paramref name="paramNameRegex"/>, replace its value with
    /// <see cref="RedactedMarker"/>. Re-serialise. No-op pass-through when the
    /// input is not parseable JSON, is not a JSON object, or does not carry a
    /// top-level <c>"parameters"</c> object. On any unexpected fault the field
    /// is over-redacted with <see cref="RedactorErrorMarker"/> and
    /// <paramref name="onFailure"/> is invoked.
    /// </summary>
    /// <param name="json">The raw JSON string to redact; null passes through as null.</param>
    /// <param name="paramNameRegex">Compiled regex matched against each SQL parameter name.</param>
    /// <param name="logger">Logger for warning diagnostics on redactor faults.</param>
    /// <param name="onFailure">Callback invoked when the redactor stage faults; used to increment health counters.</param>
    /// <returns>The re-serialized JSON with matched parameter values replaced by <see cref="RedactedMarker"/>, the original string if no parameters matched, or <see cref="RedactorErrorMarker"/> on fault.</returns>
    public static string? RedactSqlParameters(
        string? json,
        Regex paramNameRegex,
        ILogger logger,
        Action onFailure)
    {
        if (json is null)
        {
            return null;
        }

        var trimmed = json.AsSpan().TrimStart();
        if (trimmed.Length == 0 || trimmed[0] != '{')
        {
            return json;
        }

        try
        {
            JsonNode? root;
            try
            {
                root = JsonNode.Parse(json);
            }
            catch (JsonException)
            {
                return json;
            }

            if (root is not JsonObject obj || obj["parameters"] is not JsonObject parameters)
            {
                return json;
            }

            // Snapshot the names — mutating during enumeration is unsupported.
            var names = new List<string>(parameters.Count);
            foreach (var kvp in parameters)
            {
                names.Add(kvp.Key);
            }
            var anyChanged = false;
            foreach (var name in names)
            {
                bool matched;
                try
                {
                    matched = paramNameRegex.IsMatch(name);
                }
                catch (Exception ex)
                {
                    logger.LogWarning(
                        ex,
                        "SQL parameter redactor faulted; over-redacting field with '{Marker}'",
                        RedactorErrorMarker);
                    try { onFailure(); } catch { /* swallow per §7 */ }
                    return RedactorErrorMarker;
                }
                if (matched)
                {
                    parameters[name] = JsonValue.Create(RedactedMarker);
                    anyChanged = true;
                }
            }

            // Avoid re-serialising (which would normalise whitespace / order)
            // when no parameter matched — keeps the on-disk row byte-identical
            // to the emitter's output on the no-match path.
            return anyChanged ? obj.ToJsonString(RedactedSummaryJsonOptions) : json;
        }
        catch (Exception ex)
        {
            logger.LogWarning(
                ex,
                "SQL parameter redactor faulted; over-redacting field with '{Marker}'",
                RedactorErrorMarker);
            try { onFailure(); } catch { /* swallow per §7 */ }
            return RedactorErrorMarker;
        }
    }

    /// <summary>
    /// Truncate <paramref name="value"/> to <paramref name="cap"/> UTF-8 bytes,
    /// setting <paramref name="truncated"/> to <c>true</c> when the value was
    /// shortened. Null passes through as null.
    /// </summary>
    /// <param name="value">The string to truncate; null passes through as null.</param>
    /// <param name="cap">Maximum number of UTF-8 bytes to retain.</param>
    /// <param name="truncated">Set to <c>true</c> when the value was shortened; unchanged otherwise.</param>
    /// <returns>The truncated string, the original string if within the cap, or <c>null</c> if the input was null.</returns>
    public static string? TruncateField(string? value, int cap, ref bool truncated)
    {
        if (value is null)
        {
            return null;
        }
        var result = TruncateUtf8(value, cap);
        // Char-count comparison is sufficient: TruncateUtf8 only ever shortens the
        // string, so result.Length < value.Length iff bytes were removed.
        if (result.Length != value.Length)
        {
            truncated = true;
        }
        return result;
    }

    /// <summary>
    /// UTF-8 byte-safe truncation. Encodes the input to UTF-8, walks back from
    /// the cap position until the byte is NOT a continuation byte
    /// (<c>byte &amp; 0xC0 == 0x80</c>), and decodes the resulting prefix —
    /// guaranteeing the returned string never splits a multi-byte sequence.
    /// </summary>
    /// <param name="value">The string to truncate.</param>
    /// <param name="capBytes">Maximum number of UTF-8 bytes in the returned string.</param>
    /// <returns>The truncated string guaranteed not to split a multi-byte UTF-8 sequence, or the original string if within the cap.</returns>
    public static string TruncateUtf8(string value, int capBytes)
    {
        if (string.IsNullOrEmpty(value))
        {
            return value;
        }
        var bytes = Encoding.UTF8.GetBytes(value);
        if (bytes.Length <= capBytes)
        {
            return value;
        }
        var boundary = capBytes;
        while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
        {
            boundary--;
        }
        return Encoding.UTF8.GetString(bytes, 0, boundary);
    }
}
