using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Configuration;
using ZB.MOM.WW.ScadaBridge.AuditLog.Payload;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

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

/// <summary>
/// Canonical <see cref="IAuditRedactor"/> implementation for ScadaBridge —
/// operates on <c>ZB.MOM.WW.Audit.AuditEvent</c> and its <see cref="AuditEvent.DetailsJson"/>
/// payload bag. The ScadaBridge request/response/error/extra summaries travel
/// inside <c>DetailsJson</c> as a <see cref="AuditDetails"/> record (serialized
/// by <see cref="AuditDetailsCodec"/>); this redactor deserializes them, applies
/// the header → body-regex → SQL-parameter → byte-safe truncation pipeline,
/// re-serializes, and returns a filtered COPY.
/// </summary>
/// <remarks>
/// <para>
/// Cap selection is faithful to the original pipeline, translated onto canonical
/// fields:
/// <list type="bullet">
///   <item>The <c>ApiInbound</c> branch keys on <see cref="AuditEvent.Category"/>
///     (= <c>AuditChannel.ToString()</c> per <see cref="AuditFieldBuilders.BuildCategory"/>)
///     → <see cref="AuditLogOptions.InboundMaxBytes"/>.</item>
///   <item>The "error row" branch reproduces the legacy
///     <c>IsErrorStatus(Status)</c> rule — Status NOT IN (<c>Delivered</c>,
///     <c>Submitted</c>, <c>Forwarded</c>) → <see cref="AuditLogOptions.ErrorCapBytes"/>.
///     The fine-grained status is read from <see cref="AuditDetails.Status"/>
///     when present (it must be — <see cref="AuditOutcome"/> alone cannot
///     reproduce <c>IsErrorStatus</c>, since <c>Attempted</c>/<c>Skipped</c>
///     project to <see cref="AuditOutcome.Success"/> yet take the error cap).
///     When <see cref="AuditDetails.Status"/> is absent/unparseable the
///     canonical <see cref="AuditEvent.Outcome"/> is the fallback:
///     <see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
///     → error cap.</item>
/// </list>
/// </para>
/// <para>
/// MUST NOT throw — wrapped in try/catch; over-redacts (drops ALL sensitive free-text
/// fields to a safe marker) on any internal failure, mirroring
/// <see cref="SafeDefaultAuditRedactor"/>.
/// </para>
/// </remarks>
public sealed class ScadaBridgeAuditRedactor : IAuditRedactor, IDisposable
{
    private const string OverRedactedMarker = AuditRedactionPrimitives.OverRedactedEventMarker;

    private readonly IOptionsMonitor<AuditLogOptions> _options;
    private readonly ILogger<ScadaBridgeAuditRedactor> _logger;
    private readonly IAuditRedactionFailureCounter _failureCounter;
    private readonly AuditRegexCache _regexCache;
    private readonly IDisposable? _optionsReload;

    /// <summary>
    /// Primary constructor used by DI — pulls the optional redaction-failure
    /// counter from the container; a NoOp default is used when none is supplied.
    /// </summary>
    /// <param name="options">Live-reloadable audit log options.</param>
    /// <param name="logger">Logger for redaction diagnostics.</param>
    /// <param name="failureCounter">Optional counter incremented when a redaction operation fails; defaults to a no-op.</param>
    public ScadaBridgeAuditRedactor(
        IOptionsMonitor<AuditLogOptions> options,
        ILogger<ScadaBridgeAuditRedactor> logger,
        IAuditRedactionFailureCounter? failureCounter = null)
    {
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _failureCounter = failureCounter ?? new NoOpAuditRedactionFailureCounter();
        _regexCache = new AuditRegexCache(_logger);

        // Precompile at construction and on every reload, per the audit-log
        // roadmap ("patterns precompiled at startup; rejected if compile takes
        // >100ms"). Compiling lazily on first event instead put a wall-clock
        // budget on the hot path, where RegexOptions.Compiled IL emission can
        // exceed it under load and reject a valid pattern.
        WarmCurrentPatterns();
        _optionsReload = _options.OnChange(WarmRedactorPatterns);
    }

    /// <summary>
    /// Warm from the current options snapshot. Reading <c>CurrentValue</c> happens
    /// INSIDE the try: an options provider that throws must not take the
    /// constructor down — <see cref="Apply"/> owns that failure and answers it by
    /// over-redacting, which is the behaviour
    /// <c>OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted</c>
    /// pins.
    /// </summary>
    private void WarmCurrentPatterns()
    {
        try
        {
            WarmRedactorPatterns(_options.CurrentValue);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(
                ex, "Could not read audit options to precompile redactor patterns; deferring to first use");
        }
    }

    /// <summary>
    /// Compile every configured body / SQL-parameter redactor pattern so the
    /// compile budget is spent once, off the audit hot path.
    /// </summary>
    /// <remarks>
    /// Warming is best-effort and deliberately does NOT fail the boot: an
    /// unusable pattern degrades that node to over-redaction (safe, loud) rather
    /// than taking the node down. There is still a narrow window after a reload
    /// where an event can beat the warm-up and compile on the hot path — that
    /// path now fails closed, so the worst case is over-redaction, never a
    /// silently unredacted payload.
    /// </remarks>
    private void WarmRedactorPatterns(AuditLogOptions? opts)
    {
        if (opts == null)
        {
            return;
        }

        try
        {
            var unavailable = _regexCache.Warm(opts.GlobalBodyRedactors);
            foreach (var over in opts.PerTargetOverrides.Values)
            {
                unavailable += _regexCache.Warm(over.AdditionalBodyRedactors);
                if (!string.IsNullOrEmpty(over.RedactSqlParamsMatching))
                {
                    unavailable += _regexCache.Warm(new[] { "(?i)" + over.RedactSqlParamsMatching });
                }
            }

            if (unavailable > 0)
            {
                _logger.LogWarning(
                    "{Count} configured audit redactor pattern(s) are unavailable; audit payloads "
                        + "matching them will be over-redacted until the patterns are fixed.",
                    unavailable);
            }
        }
        catch (Exception ex)
        {
            // Never let warm-up break construction or an options reload; the
            // lazy path remains as the (fail-closed) fallback.
            _logger.LogWarning(ex, "Audit redactor pattern warm-up failed; patterns will compile on first use");
        }
    }

    /// <inheritdoc />
    public void Dispose() => _optionsReload?.Dispose();

    /// <summary>
    /// Applies the full redaction pipeline to <paramref name="rawEvent"/> and returns a
    /// filtered copy; returns the same instance unchanged on the fast path. Never throws.
    /// </summary>
    /// <param name="rawEvent">The raw audit event to redact.</param>
    /// <returns>A redacted copy of <paramref name="rawEvent"/>, or the original instance when no changes are needed.</returns>
    public AuditEvent Apply(AuditEvent rawEvent)
    {
        try
        {
            var opts = _options.CurrentValue;

            // --- Fast path -------------------------------------------------
            // Mirror the legacy filter's non-JSON pre-check: when there is no
            // DetailsJson payload to scrub AND the Target is within the cap,
            // there is nothing to redact or truncate. Return the input
            // unchanged so the common case stays cheap (no Deserialize, no
            // re-Serialize, same instance back).
            var detailsEmpty = string.IsNullOrEmpty(rawEvent.DetailsJson);
            var targetWithinCap = rawEvent.Target is null
                || Encoding.UTF8.GetByteCount(rawEvent.Target) <= opts.DefaultCapBytes;
            if (detailsEmpty && targetWithinCap)
            {
                return rawEvent;
            }

            // --- Slow path -------------------------------------------------
            var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson);

            // Cap selection. Channel = canonical Category (the ApiInbound
            // branch); error-cap selection reproduces the legacy
            // IsErrorStatus(Status) — read from d.Status when present, else
            // fall back to the canonical Outcome.
            var cap = SelectCap(opts, rawEvent.Category, d.Status, rawEvent.Outcome);

            // --- Header-redaction stage (runs BEFORE truncation) ----------
            var request = RedactHeaders(d.RequestSummary, opts.HeaderRedactList);
            var response = RedactHeaders(d.ResponseSummary, opts.HeaderRedactList);
            var errorDetail = d.ErrorDetail;
            var extra = d.Extra;

            // --- Body-regex stage (also runs BEFORE truncation) -----------
            // Per-target additions key on the canonical Target.
            var bodyRegexes = ResolveBodyRegexes(opts, rawEvent.Target, out var bodyRedactorUnavailable);

            // FAIL CLOSED. A configured redactor that will not compile must never
            // degrade to "emit the body raw" — that publishes precisely the values
            // the operator asked to suppress, on a row that looks entirely normal.
            if (bodyRedactorUnavailable)
            {
                return OverRedactUnavailable(rawEvent, "body");
            }

            if (bodyRegexes.Count > 0)
            {
                request = RedactBody(request, bodyRegexes);
                response = RedactBody(response, bodyRegexes);
                errorDetail = RedactBody(errorDetail, bodyRegexes);
                extra = RedactBody(extra, bodyRegexes);
            }

            // --- SQL parameter redaction stage (DbOutbound only) ----------
            // Channel-guarded on the canonical Category; connection key is the
            // Target prefix before the first '.'.
            if (string.Equals(rawEvent.Category, nameof(AuditChannel.DbOutbound), StringComparison.Ordinal))
            {
                if (TryGetSqlParamRedactor(
                        opts, rawEvent.Target, out var sqlParamRegex, out var sqlRedactorUnavailable))
                {
                    request = RedactSqlParameters(request, sqlParamRegex!);
                }
                else if (sqlRedactorUnavailable)
                {
                    return OverRedactUnavailable(rawEvent, "SQL-parameter");
                }
            }

            // --- Truncation stage -----------------------------------------
            var truncated = false;
            request = TruncateField(request, cap, ref truncated);
            response = TruncateField(response, cap, ref truncated);
            errorDetail = TruncateField(errorDetail, cap, ref truncated);
            extra = TruncateField(extra, cap, ref truncated);

            var rewritten = d with
            {
                RequestSummary = request,
                ResponseSummary = response,
                ErrorDetail = errorDetail,
                Extra = extra,
                PayloadTruncated = d.PayloadTruncated || truncated,
            };

            // Target length cap (canonical top-level field). Cap at the default
            // byte ceiling so an absurd Target cannot blow the storage column.
            var cappedTarget = TruncateTarget(rawEvent.Target, opts.DefaultCapBytes);

            return rawEvent with
            {
                DetailsJson = AuditDetailsCodec.Serialize(rewritten),
                Target = cappedTarget,
            };
        }
        catch (Exception ex)
        {
            // Audit is best-effort: over-redact rather than fail the caller.
            // Drop the summaries entirely (mirroring SafeDefault's catch path)
            // and flag PayloadTruncated so downstream readers know the row was
            // scrubbed defensively.
            _logger.LogWarning(
                ex,
                "Canonical audit redactor failed; over-redacting DetailsJson and flagging PayloadTruncated");
            IncrementFailureCounter();
            return OverRedact(rawEvent);
        }
    }

    /// <summary>
    /// Pick the truncation cap. <paramref name="category"/> = canonical Category
    /// (= channel name): <c>ApiInbound</c> → <see cref="AuditLogOptions.InboundMaxBytes"/>.
    /// Otherwise the legacy <c>IsErrorStatus</c> rule decides between the error
    /// and default caps, preferring the fine-grained <paramref name="detailsStatus"/>
    /// (from <c>DetailsJson</c>) and falling back to the canonical
    /// <paramref name="outcome"/> when status is absent/unparseable.
    /// </summary>
    private static int SelectCap(
        AuditLogOptions opts,
        string? category,
        string? detailsStatus,
        AuditOutcome outcome)
    {
        if (string.Equals(category, nameof(AuditChannel.ApiInbound), StringComparison.Ordinal))
        {
            return opts.InboundMaxBytes;
        }
        return IsErrorRow(detailsStatus, outcome) ? opts.ErrorCapBytes : opts.DefaultCapBytes;
    }

    /// <summary>
    /// Reproduce the legacy <c>IsErrorStatus(Status)</c> error-cap predicate on
    /// the canonical record: Status NOT IN (<c>Delivered</c>, <c>Submitted</c>,
    /// <c>Forwarded</c>) → error row. When the fine-grained status is present in
    /// <c>DetailsJson</c> it is authoritative; otherwise the canonical
    /// <see cref="AuditOutcome"/> is the fallback
    /// (<see cref="AuditOutcome.Failure"/>/<see cref="AuditOutcome.Denied"/>
    /// → error row).
    /// </summary>
    private static bool IsErrorRow(string? detailsStatus, AuditOutcome outcome)
    {
        if (!string.IsNullOrEmpty(detailsStatus)
            && Enum.TryParse<AuditStatus>(detailsStatus, ignoreCase: false, out var status))
        {
            return status switch
            {
                AuditStatus.Delivered or AuditStatus.Submitted or AuditStatus.Forwarded => false,
                _ => true,
            };
        }
        // No usable status — fall back to the canonical outcome.
        return outcome != AuditOutcome.Success;
    }

    /// <summary>
    /// Fail-closed exit: a redactor the operator configured is unavailable, so the
    /// payload is suppressed wholesale rather than emitted unredacted. Counted on
    /// the same <see cref="IAuditRedactionFailureCounter"/> as other redaction
    /// failures so a disabled redactor is observable, not just a log line.
    /// </summary>
    private AuditEvent OverRedactUnavailable(AuditEvent rawEvent, string stage)
    {
        _logger.LogWarning(
            "A configured {Stage} redactor is unavailable (invalid or over the compile budget); "
                + "over-redacting DetailsJson rather than emitting it unredacted. "
                + "Fix or remove the pattern — audit payloads stay suppressed until then.",
            stage);
        IncrementFailureCounter();
        return OverRedact(rawEvent);
    }

    private string? RedactHeaders(string? json, IList<string> redactList)
        => AuditRedactionPrimitives.RedactHeaders(json, redactList, _logger, IncrementFailureCounter);

    private string? RedactBody(string? value, IReadOnlyList<Regex> regexes)
        => AuditRedactionPrimitives.RedactBody(value, regexes, _logger, IncrementFailureCounter);

    private string? RedactSqlParameters(string? json, Regex paramNameRegex)
        => AuditRedactionPrimitives.RedactSqlParameters(json, paramNameRegex, _logger, IncrementFailureCounter);

    private static string? TruncateField(string? value, int cap, ref bool truncated)
        => AuditRedactionPrimitives.TruncateField(value, cap, ref truncated);

    private static string? TruncateTarget(string? target, int cap)
        => target is null ? null : AuditRedactionPrimitives.TruncateUtf8(target, cap);

    /// <summary>
    /// Combine the global and per-target body-redactor lists, returning the
    /// compiled-regex set to apply.
    /// </summary>
    /// <remarks>
    /// <paramref name="anyUnavailable"/> is set when a pattern the operator
    /// CONFIGURED could not be compiled (invalid, or over the cache's compile
    /// budget). The caller must then fail closed — dropping the pattern and
    /// emitting the body anyway would silently publish exactly the values the
    /// operator asked to have redacted.
    /// </remarks>
    private IReadOnlyList<Regex> ResolveBodyRegexes(
        AuditLogOptions opts, string? target, out bool anyUnavailable)
    {
        anyUnavailable = false;

        var hasGlobal = opts.GlobalBodyRedactors is { Count: > 0 };
        var perTargetAdditions = (target != null
            && opts.PerTargetOverrides.TryGetValue(target, out var over)
            && over.AdditionalBodyRedactors is { Count: > 0 })
            ? over.AdditionalBodyRedactors
            : null;

        if (!hasGlobal && perTargetAdditions == null)
        {
            return Array.Empty<Regex>();
        }

        var result = new List<Regex>();
        if (hasGlobal)
        {
            foreach (var pattern in opts.GlobalBodyRedactors)
            {
                if (_regexCache.TryGet(pattern, out var rx))
                {
                    result.Add(rx!);
                }
                else
                {
                    anyUnavailable = true;
                }
            }
        }
        if (perTargetAdditions != null)
        {
            foreach (var pattern in perTargetAdditions)
            {
                if (_regexCache.TryGet(pattern, out var rx))
                {
                    result.Add(rx!);
                }
                else
                {
                    anyUnavailable = true;
                }
            }
        }
        return result;
    }

    /// <summary>
    /// Resolve the per-connection SQL parameter redaction regex for the given
    /// target. Connection key = everything before the first <c>.</c> in
    /// <paramref name="target"/>. Patterns are forced case-insensitive.
    /// </summary>
    /// <remarks>
    /// <paramref name="unavailable"/> separates "no SQL redactor is configured for
    /// this connection" (fine — return false, capture parameters as normal) from
    /// "one IS configured but will not compile" (fail closed). Collapsing the two
    /// into a bare <c>false</c> is what let a configured redactor silently emit
    /// parameter values verbatim.
    /// </remarks>
    private bool TryGetSqlParamRedactor(
        AuditLogOptions opts, string? target, out Regex? regex, out bool unavailable)
    {
        regex = null;
        unavailable = false;
        if (string.IsNullOrEmpty(target))
        {
            return false;
        }

        var dot = target.IndexOf('.');
        var connectionKey = dot < 0 ? target : target[..dot];

        if (!opts.PerTargetOverrides.TryGetValue(connectionKey, out var over)
            || string.IsNullOrEmpty(over.RedactSqlParamsMatching))
        {
            return false;
        }

        var cacheKey = "(?i)" + over.RedactSqlParamsMatching;
        if (_regexCache.TryGet(cacheKey, out regex))
        {
            return true;
        }

        unavailable = true;
        return false;
    }

    /// <summary>
    /// Over-redaction copy returned from the never-throws catch: suppress ALL
    /// potentially-sensitive string fields inside <c>DetailsJson</c> to a safe
    /// marker and flag <see cref="AuditDetails.PayloadTruncated"/>. "All sensitive
    /// fields" = <c>RequestSummary</c>, <c>ResponseSummary</c>, <c>ErrorDetail</c>,
    /// <c>ErrorMessage</c>, and <c>Extra</c> — all body-regex redaction targets
    /// that can carry sensitive values. Best-effort re-serialise; if even that
    /// fails, return the input with no sensitive fields via a minimal details bag.
    /// </summary>
    private static AuditEvent OverRedact(AuditEvent rawEvent)
    {
        try
        {
            var d = AuditDetailsCodec.Deserialize(rawEvent.DetailsJson) with
            {
                RequestSummary = OverRedactedMarker,
                ResponseSummary = OverRedactedMarker,
                ErrorDetail = OverRedactedMarker,
                ErrorMessage = OverRedactedMarker,
                Extra = OverRedactedMarker,
                PayloadTruncated = true,
            };
            return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(d) };
        }
        catch
        {
            var safe = new AuditDetails
            {
                RequestSummary = OverRedactedMarker,
                ResponseSummary = OverRedactedMarker,
                ErrorDetail = OverRedactedMarker,
                ErrorMessage = OverRedactedMarker,
                Extra = OverRedactedMarker,
                PayloadTruncated = true,
            };
            return rawEvent with { DetailsJson = AuditDetailsCodec.Serialize(safe) };
        }
    }

    /// <summary>
    /// Bumps the injected redaction-failure counter, swallowing any fault per
    /// alog.md §7. Passed as the <c>onFailure</c> callback to the shared
    /// primitives and called from the top-level catch.
    /// </summary>
    private void IncrementFailureCounter()
    {
        try { _failureCounter.Increment(); } catch { /* swallow per §7 */ }
    }
}
