using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
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.AuditLog.Redaction;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

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

/// <summary>
/// ScadaBridge audit re-architecture stage C2 (Task 2.5) tests for
/// <see cref="ScadaBridgeAuditRedactor"/> — the canonical
/// <see cref="IAuditRedactor"/> implementation. Covers the header-redaction /
/// body-regex / SQL-param / safety-net / truncation pipeline operating on
/// canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> records built via
/// <see cref="AuditDetailsCodec"/>.
/// </summary>
public class ScadaBridgeAuditRedactorTests
{
    private static ScadaBridgeAuditRedactor Redactor(
        AuditLogOptions? opts = null,
        IAuditRedactionFailureCounter? counter = null) =>
        new(new StaticMonitor(opts ?? new AuditLogOptions()),
            NullLogger<ScadaBridgeAuditRedactor>.Instance,
            counter);

    /// <summary>
    /// Build a canonical event whose <see cref="AuditEvent.DetailsJson"/> carries the
    /// supplied summaries + channel/status, mirroring what the C3 emit boundary
    /// will produce. Category = channel name (so the ApiInbound branch is keyed
    /// correctly); Status travels inside DetailsJson for the fine-grained error
    /// cap selection.
    /// </summary>
    private static AuditEvent NewEvent(
        AuditChannel channel = AuditChannel.ApiOutbound,
        AuditStatus status = AuditStatus.Delivered,
        AuditOutcome outcome = AuditOutcome.Success,
        string? request = null,
        string? response = null,
        string? errorDetail = null,
        string? extra = null,
        string? target = null,
        bool detailsPayloadTruncated = false)
    {
        var details = new AuditDetails
        {
            Channel = channel.ToString(),
            Status = status.ToString(),
            RequestSummary = request,
            ResponseSummary = response,
            ErrorDetail = errorDetail,
            Extra = extra,
            PayloadTruncated = detailsPayloadTruncated,
        };
        return new AuditEvent
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Actor = "tester",
            Action = AuditFieldBuilders.BuildAction(channel, AuditKind.ApiCall),
            Category = AuditFieldBuilders.BuildCategory(channel),
            Outcome = outcome,
            Target = target,
            DetailsJson = AuditDetailsCodec.Serialize(details),
        };
    }

    private static AuditDetails Details(AuditEvent evt) =>
        AuditDetailsCodec.Deserialize(evt.DetailsJson);

    // ---- Header redaction (ports HeaderRedactionTests) ---------------------

    [Fact]
    public void HeaderRedaction_AuthorizationBearer_Redacted()
    {
        var request = "{\"headers\":{\"Authorization\":\"Bearer secret-token-xyz\",\"Content-Type\":\"application/json\"},\"body\":\"hello\"}";
        var evt = NewEvent(request: request);

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.NotNull(d.RequestSummary);
        Assert.Contains("\"Authorization\":\"<redacted>\"", d.RequestSummary);
        Assert.DoesNotContain("secret-token-xyz", d.RequestSummary);
        Assert.Contains("application/json", d.RequestSummary);
    }

    [Fact]
    public void HeaderRedaction_CaseInsensitive_LowercaseAuthorization_Redacted()
    {
        var request = "{\"headers\":{\"authorization\":\"Bearer secret-token-xyz\"},\"body\":\"hi\"}";
        var evt = NewEvent(request: request);

        var result = Redactor().Apply(evt);

        Assert.Contains("\"authorization\":\"<redacted>\"", Details(result).RequestSummary!);
    }

    [Fact]
    public void HeaderRedaction_CustomRedactList_RedactsCustomHeaderName()
    {
        var opts = new AuditLogOptions { HeaderRedactList = new List<string> { "X-Custom-Secret" } };
        var request = "{\"headers\":{\"X-Custom-Secret\":\"topsecret\",\"Authorization\":\"Bearer keep-me\"},\"body\":\"hi\"}";
        var evt = NewEvent(request: request);

        var result = Redactor(opts).Apply(evt);

        var d = Details(result);
        Assert.Contains("\"X-Custom-Secret\":\"<redacted>\"", d.RequestSummary!);
        Assert.Contains("Bearer keep-me", d.RequestSummary!);
    }

    [Fact]
    public void HeaderRedaction_NonJson_RequestSummary_Unchanged()
    {
        var evt = NewEvent(request: "this is not JSON at all");

        var result = Redactor().Apply(evt);

        Assert.Equal("this is not JSON at all", Details(result).RequestSummary);
    }

    // ---- Body regex redaction (ports BodyRegexRedactionTests) --------------

    [Fact]
    public void GlobalRegex_HunterPassword_Redacted()
    {
        var opts = new AuditLogOptions
        {
            GlobalBodyRedactors = new List<string> { "\"password\":\\s*\"[^\"]*\"" },
        };
        var evt = NewEvent(request: "{\"user\":\"alice\",\"password\":\"hunter2\"}");

        var result = Redactor(opts).Apply(evt);

        var d = Details(result);
        Assert.Contains("<redacted>", d.RequestSummary!);
        Assert.DoesNotContain("hunter2", d.RequestSummary!);
        Assert.Contains("alice", d.RequestSummary!);
    }

    [Fact]
    public void BodyRegex_AppliesToErrorDetailAndExtra()
    {
        var opts = new AuditLogOptions
        {
            GlobalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
        };
        var evt = NewEvent(
            errorDetail: "boom SECRET-AAA111 boom",
            extra: "ctx SECRET-BBB222 ctx");

        var result = Redactor(opts).Apply(evt);

        var d = Details(result);
        Assert.DoesNotContain("SECRET-AAA111", d.ErrorDetail!);
        Assert.DoesNotContain("SECRET-BBB222", d.Extra!);
        Assert.Contains("<redacted>", d.ErrorDetail!);
        Assert.Contains("<redacted>", d.Extra!);
    }

    [Fact]
    public void PerTargetRegex_OnlyAppliedToMatchingTarget()
    {
        var opts = new AuditLogOptions
        {
            PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
            {
                ["esg.A"] = new PerTargetRedactionOverride
                {
                    AdditionalBodyRedactors = new List<string> { "SECRET-[A-Z0-9]+" },
                },
            },
        };
        const string input = "token=SECRET-XYZ123 normal-text";

        var matched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.A"));
        Assert.Contains("<redacted>", Details(matched).RequestSummary!);
        Assert.DoesNotContain("SECRET-XYZ123", Details(matched).RequestSummary!);

        var unmatched = Redactor(opts).Apply(NewEvent(request: input, target: "esg.B"));
        Assert.Equal(input, Details(unmatched).RequestSummary);
    }

    [Fact]
    public void NoRegexConfigured_FieldUnchanged()
    {
        var evt = NewEvent(request: "{\"password\":\"hunter2\"}");

        var result = Redactor().Apply(evt);

        Assert.Equal("{\"password\":\"hunter2\"}", Details(result).RequestSummary);
    }

    [Fact]
    public void RegexThrowsTimeout_FieldBecomesRedactedMarker_CounterIncrements()
    {
        var opts = new AuditLogOptions { GlobalBodyRedactors = new List<string> { "^(a+)+$" } };
        var counter = new CountingRedactionFailureCounter();
        var evt = NewEvent(request: new string('a', 30) + "!");

        var result = Redactor(opts, counter).Apply(evt);

        Assert.Equal("<redacted: redactor error>", Details(result).RequestSummary);
        Assert.True(counter.Count >= 1, $"expected counter >= 1, got {counter.Count}");
    }

    // ---- SQL parameter redaction (ports SqlParamRedactionTests) ------------

    private static string DbRequestSummary(string sql, params (string name, string value)[] parameters)
    {
        var sb = new StringBuilder();
        sb.Append("{\"sql\":\"").Append(sql).Append('"');
        if (parameters.Length > 0)
        {
            sb.Append(",\"parameters\":{");
            for (var i = 0; i < parameters.Length; i++)
            {
                if (i > 0) sb.Append(',');
                sb.Append('"').Append(parameters[i].name).Append("\":\"")
                    .Append(parameters[i].value).Append('"');
            }
            sb.Append('}');
        }
        sb.Append('}');
        return sb.ToString();
    }

    [Fact]
    public void NoOptIn_ParamsVerbatim_Unchanged()
    {
        var input = DbRequestSummary(
            "INSERT INTO Users (Name, Token) VALUES (@name, @token)",
            ("@name", "Alice"), ("@token", "secret-xyz"));
        var evt = NewEvent(
            channel: AuditChannel.DbOutbound, status: AuditStatus.Delivered,
            request: input, target: "PrimaryDb.INSERT INTO Users");

        var result = Redactor().Apply(evt);

        Assert.Equal(input, Details(result).RequestSummary);
    }

    [Fact]
    public void OptInRegex_AtToken_RedactsThoseValues_KeepsOthers()
    {
        var opts = new AuditLogOptions
        {
            PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
            {
                ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@(token|apikey)$" },
            },
        };
        var input = DbRequestSummary(
            "INSERT INTO Users (Name, Token, ApiKey) VALUES (@name, @token, @apikey)",
            ("@name", "Alice"), ("@token", "secret-xyz"), ("@apikey", "k-987"));
        var evt = NewEvent(
            channel: AuditChannel.DbOutbound, request: input, target: "PrimaryDb.INSERT INTO Users");

        var result = Redactor(opts).Apply(evt);

        var d = Details(result);
        Assert.Contains("\"@name\":\"Alice\"", d.RequestSummary!);
        Assert.Contains("\"@token\":\"<redacted>\"", d.RequestSummary!);
        Assert.Contains("\"@apikey\":\"<redacted>\"", d.RequestSummary!);
        Assert.DoesNotContain("secret-xyz", d.RequestSummary!);
        Assert.DoesNotContain("k-987", d.RequestSummary!);
    }

    [Fact]
    public void NonDbOutboundChannel_NotAffected()
    {
        var opts = new AuditLogOptions
        {
            PerTargetOverrides = new Dictionary<string, PerTargetRedactionOverride>
            {
                ["PrimaryDb"] = new PerTargetRedactionOverride { RedactSqlParamsMatching = "^@token$" },
            },
        };
        var input = DbRequestSummary("SELECT @token", ("@token", "should-survive"));
        // ApiOutbound channel whose summary *looks* like the DbOutbound shape.
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, request: input, target: "PrimaryDb.SELECT");

        var result = Redactor(opts).Apply(evt);

        Assert.Equal(input, Details(result).RequestSummary);
    }

    // ---- Truncation + cap selection (ports TruncationTests) ----------------

    [Fact]
    public void SuccessRow_10KB_RequestSummary_TruncatedTo8KB_PayloadTruncatedTrue()
    {
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Delivered,
            outcome: AuditOutcome.Success, request: new string('a', 10 * 1024));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
        Assert.True(d.PayloadTruncated);
    }

    [Fact]
    public void ErrorRow_Failed_10KB_RequestSummary_NotTruncated_UnderErrorCap()
    {
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
            outcome: AuditOutcome.Failure, request: new string('b', 10 * 1024));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal(new string('b', 10 * 1024), d.RequestSummary);
        Assert.False(d.PayloadTruncated);
    }

    [Fact]
    public void ErrorRow_Failed_70KB_RequestSummary_TruncatedTo64KB()
    {
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Failed,
            outcome: AuditOutcome.Failure, request: new string('c', 70 * 1024));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal(65536, Encoding.UTF8.GetByteCount(d.RequestSummary!));
        Assert.True(d.PayloadTruncated);
    }

    [Fact]
    public void StatusAttempted_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
    {
        // Attempted projects to Outcome.Success, yet IsErrorStatus(Attempted)==true.
        // Faithful port must read d.Status and pick the error cap — a 10 KB body
        // must survive (under the 64 KiB error cap), NOT truncate to 8 KiB.
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Attempted,
            outcome: AuditOutcome.Success, request: new string('d', 10 * 1024));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal(new string('d', 10 * 1024), d.RequestSummary);
        Assert.False(d.PayloadTruncated);
    }

    [Fact]
    public void StatusSkipped_TreatedAsError_UsesErrorCap_EvenThoughOutcomeSuccess()
    {
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Skipped,
            outcome: AuditOutcome.Success, request: new string('f', 10 * 1024));

        var result = Redactor().Apply(evt);

        Assert.False(Details(result).PayloadTruncated);
    }

    [Fact]
    public void StatusSubmitted_TreatedAsSuccess_UsesDefaultCap()
    {
        // Submitted is NOT an error status (IsErrorStatus==false) → default 8 KiB cap.
        var evt = NewEvent(
            channel: AuditChannel.ApiOutbound, status: AuditStatus.Submitted,
            outcome: AuditOutcome.Success, request: new string('g', 10 * 1024));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal(8192, Encoding.UTF8.GetByteCount(d.RequestSummary!));
        Assert.True(d.PayloadTruncated);
    }

    [Fact]
    public void ApiInbound_LargeBody_UsesInboundCap_NotDefault()
    {
        var evt = NewEvent(
            channel: AuditChannel.ApiInbound, status: AuditStatus.Delivered,
            outcome: AuditOutcome.Success, request: new string('a', 100_000));

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.False(d.PayloadTruncated);
        Assert.Equal(100_000, Encoding.UTF8.GetByteCount(d.RequestSummary!));
    }

    [Fact]
    public void ApiInbound_Failed_BodyAboveInboundMaxBytes_TruncatedToInboundMaxBytes()
    {
        var opts = new AuditLogOptions { InboundMaxBytes = 16_384 };
        var evt = NewEvent(
            channel: AuditChannel.ApiInbound, status: AuditStatus.Failed,
            outcome: AuditOutcome.Failure, response: new string('z', 50_000));

        var result = Redactor(opts).Apply(evt);

        var d = Details(result);
        Assert.True(d.PayloadTruncated);
        Assert.True(Encoding.UTF8.GetByteCount(d.ResponseSummary!) <= 16_384);
    }

    [Fact]
    public void Multibyte_UTF8_TruncatedAtCharacterBoundary_NotMidByte()
    {
        var sb = new StringBuilder();
        for (int i = 0; i < 2100; i++) sb.Append("😀");
        var input = sb.ToString();
        var evt = NewEvent(request: input);

        var result = Redactor().Apply(evt);

        var d = Details(result);
        var bytes = Encoding.UTF8.GetByteCount(d.RequestSummary!);
        Assert.True(bytes <= 8192);
        Assert.Equal(0, bytes % 4);
        Assert.DoesNotContain('�', d.RequestSummary!);
        Assert.True(d.PayloadTruncated);
    }

    [Fact]
    public void ExistingDetailsPayloadTruncated_RemainsTrue()
    {
        var evt = NewEvent(request: "small", detailsPayloadTruncated: true);

        var result = Redactor().Apply(evt);

        var d = Details(result);
        Assert.Equal("small", d.RequestSummary);
        Assert.True(d.PayloadTruncated);
    }

    // ---- Target length cap -------------------------------------------------

    [Fact]
    public void Target_OverCap_Truncated_ToByteBoundary()
    {
        // Target is a canonical top-level field; the redactor caps it at the
        // default cap so an absurdly long target can't blow the column.
        var longTarget = new string('t', 10 * 1024);
        var evt = NewEvent(status: AuditStatus.Delivered, outcome: AuditOutcome.Success, target: longTarget);

        var result = Redactor().Apply(evt);

        Assert.NotNull(result.Target);
        Assert.True(Encoding.UTF8.GetByteCount(result.Target!) <= 8192);
    }

    // ---- Fast-path ---------------------------------------------------------

    [Fact]
    public void FastPath_NullDetailsAndShortTarget_ReturnsSameInstance()
    {
        var evt = new AuditEvent
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Actor = "tester",
            Action = "ApiOutbound.ApiCall",
            Outcome = AuditOutcome.Success,
            Target = "short",
            DetailsJson = null,
        };

        var result = Redactor().Apply(evt);

        Assert.Same(evt, result);
    }

    [Fact]
    public void FastPath_EmptyDetailsAndShortTarget_ReturnsSameInstance()
    {
        var evt = new AuditEvent
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Actor = "tester",
            Action = "ApiOutbound.ApiCall",
            Outcome = AuditOutcome.Success,
            Target = null,
            DetailsJson = "",
        };

        var result = Redactor().Apply(evt);

        Assert.Same(evt, result);
    }

    // ---- Never-throws safety net -------------------------------------------

    [Fact]
    public void MalformedDetailsJson_NeverThrows_ReturnsSafeCopy()
    {
        // Deserialize never throws (returns empty details), but a malformed
        // DetailsJson with a long Target still flows the slow path. Assert the
        // redactor returns a result without throwing.
        var evt = new AuditEvent
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Actor = "tester",
            Action = "ApiOutbound.ApiCall",
            Outcome = AuditOutcome.Success,
            Target = new string('x', 10 * 1024),
            DetailsJson = "{not valid json at all]",
        };

        var ex = Record.Exception(() => Redactor().Apply(evt));

        Assert.Null(ex);
    }

    [Fact]
    public void OuterCatch_OptionsThrows_NeverLeaks_AllSensitiveFieldsOverRedacted()
    {
        // Force the outer try/catch → OverRedact path by injecting an
        // IOptionsMonitor whose CurrentValue getter throws. This is the FIRST
        // statement in the try block, so the exception escapes before any
        // redaction work has run — the input event could still carry live
        // sensitive values in all five free-text fields.
        //
        // The never-leak contract requires ALL of RequestSummary, ResponseSummary,
        // ErrorDetail, ErrorMessage, and Extra to be suppressed to the
        // over-redacted marker, and PayloadTruncated to be true.
        var sensitiveEvent = NewEvent(
            request: "SENSITIVE-REQUEST-DATA",
            response: "SENSITIVE-RESPONSE-DATA",
            errorDetail: "SENSITIVE-ERROR-DETAIL",
            extra: "SENSITIVE-EXTRA-DATA");
        // Manually inject ErrorMessage into DetailsJson (NewEvent does not expose it).
        var withMsg = sensitiveEvent with
        {
            DetailsJson = AuditDetailsCodec.Serialize(
                AuditDetailsCodec.Deserialize(sensitiveEvent.DetailsJson) with
                {
                    ErrorMessage = "SENSITIVE-ERROR-MESSAGE",
                }),
        };

        var redactor = new ScadaBridgeAuditRedactor(
            new ThrowingMonitor(),
            Microsoft.Extensions.Logging.Abstractions.NullLogger<ScadaBridgeAuditRedactor>.Instance);

        // (a) Apply must not throw even when options access itself throws.
        var ex = Record.Exception(() => redactor.Apply(withMsg));
        Assert.Null(ex);

        // (b) Returned event must have ALL sensitive free-text fields suppressed
        //     (never-leak guarantee), and PayloadTruncated must be true.
        var result = redactor.Apply(withMsg);
        var d = Details(result);
        const string marker = "<redacted: redactor error>";

        Assert.Equal(marker, d.RequestSummary);
        Assert.Equal(marker, d.ResponseSummary);
        Assert.Equal(marker, d.ErrorDetail);
        Assert.Equal(marker, d.ErrorMessage);
        Assert.Equal(marker, d.Extra);
        Assert.True(d.PayloadTruncated);

        // Confirm no raw sensitive values survive anywhere in DetailsJson.
        Assert.DoesNotContain("SENSITIVE", result.DetailsJson ?? "");
    }

    /// <summary>Counts <see cref="IAuditRedactionFailureCounter.Increment"/> calls.</summary>
    private sealed class CountingRedactionFailureCounter : IAuditRedactionFailureCounter
    {
        private int _count;
        public int Count => _count;
        public void Increment() => System.Threading.Interlocked.Increment(ref _count);
    }

    /// <summary>IOptionsMonitor test double — returns the same snapshot on every read.</summary>
    private sealed class StaticMonitor : IOptionsMonitor<AuditLogOptions>
    {
        private readonly AuditLogOptions _value;
        public StaticMonitor(AuditLogOptions value) => _value = value;
        public AuditLogOptions CurrentValue => _value;
        public AuditLogOptions Get(string? name) => _value;
        public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
    }

    /// <summary>
    /// IOptionsMonitor test double that throws from <see cref="CurrentValue"/> to
    /// force the outer try/catch → OverRedact path in
    /// <see cref="ScadaBridgeAuditRedactor.Apply"/>.
    /// </summary>
    private sealed class ThrowingMonitor : IOptionsMonitor<AuditLogOptions>
    {
        public AuditLogOptions CurrentValue =>
            throw new InvalidOperationException("Simulated options fault for outer-catch test");
        public AuditLogOptions Get(string? name) => new AuditLogOptions();
        public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
    }
}
