using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
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.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;

namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Tests.Middleware;

/// <summary>
/// M4 Bundle D (D1) — verifies <see cref="AuditWriteMiddleware"/> emits exactly one
/// <see cref="AuditChannel.ApiInbound"/> row per request via
/// <see cref="ICentralAuditWriter"/> covering all outcome shapes:
/// success (InboundRequest/Delivered), client/server error (InboundRequest/Failed),
/// and unauthenticated (InboundAuthFailure/Failed). Audit-write failures must NEVER
/// alter the HTTP response (alog.md §13).
/// </summary>
public class AuditWriteMiddlewareTests
{
    /// <summary>
    /// Test-only recording <see cref="ICentralAuditWriter"/>. Captures every
    /// <see cref="AuditEvent"/> the middleware emits so each test can assert on
    /// the shape of the row produced for one request.
    /// </summary>
    private sealed class RecordingAuditWriter : ICentralAuditWriter
    {
        // C3 (Task 2.5): store the decomposed row view so assertions keep
        // reading the ScadaBridge domain fields as typed properties.
        public List<AuditRowProjection.AuditRowValues> Events { get; } = new();
        public Func<AuditEvent, Task>? OnWrite { get; set; }

        public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
        {
            lock (Events)
            {
                Events.Add(evt.AsRow());
            }

            return OnWrite?.Invoke(evt) ?? Task.CompletedTask;
        }
    }

    /// <summary>
    /// Builds an <see cref="HttpContext"/> primed for the inbound API route shape:
    /// POST /api/{methodName}, optional JSON body, RemoteIpAddress + User-Agent.
    /// The route value resolver mirrors the production endpoint mapping so the
    /// middleware can pull the method name without owning routing itself.
    /// </summary>
    private static DefaultHttpContext BuildContext(
        string methodName = "echo",
        string? body = null,
        string? userAgent = "test-agent/1.0",
        IPAddress? remoteIp = null)
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Method = "POST";
        ctx.Request.Path = $"/api/{methodName}";
        ctx.Request.RouteValues["methodName"] = methodName;

        if (body is not null)
        {
            var bytes = Encoding.UTF8.GetBytes(body);
            ctx.Request.Body = new MemoryStream(bytes);
            ctx.Request.ContentLength = bytes.Length;
            ctx.Request.ContentType = "application/json";
        }

        if (userAgent is not null)
        {
            ctx.Request.Headers["User-Agent"] = userAgent;
        }

        ctx.Connection.RemoteIpAddress = remoteIp ?? IPAddress.Parse("10.0.0.5");

        return ctx;
    }

    private static AuditWriteMiddleware CreateMiddleware(
        RequestDelegate next,
        ICentralAuditWriter writer,
        AuditLogOptions? options = null,
        IAuditActorAccessor? actorAccessor = null) =>
        new(
            next,
            writer,
            NullLogger<AuditWriteMiddleware>.Instance,
            new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
            actorAccessor);

    /// <summary>
    /// File-local <see cref="IAuditActorAccessor"/> test double returning a fixed
    /// actor string — stands in for the HTTP-backed accessor that reads the
    /// authenticated principal off the ambient request (Phase 3).
    /// </summary>
    private sealed class StubAuditActorAccessor : IAuditActorAccessor
    {
        public StubAuditActorAccessor(string? currentActor) => CurrentActor = currentActor;

        public string? CurrentActor { get; }
    }

    /// <summary>
    /// File-local <see cref="IOptionsMonitor{TOptions}"/> test double — returns the
    /// same snapshot on every read, no change-token plumbing required. Mirrors the
    /// <c>StaticMonitor</c> pattern in
    /// <c>tests/ZB.MOM.WW.ScadaBridge.AuditLog.Tests/Payload/InboundChannelCapTests.cs</c>.
    /// </summary>
    private sealed class StaticAuditLogOptionsMonitor : IOptionsMonitor<AuditLogOptions>
    {
        private readonly AuditLogOptions _value;

        public StaticAuditLogOptionsMonitor(AuditLogOptions value) => _value = value;

        public AuditLogOptions CurrentValue => _value;

        public AuditLogOptions Get(string? name) => _value;

        public IDisposable? OnChange(Action<AuditLogOptions, string?> listener) => null;
    }

    // ---------------------------------------------------------------------
    // 1. Happy path — InboundRequest/Delivered/HttpStatus 200
    // ---------------------------------------------------------------------

    [Fact]
    public async Task Pipeline_Success_EmitsOneEvent_KindInboundRequest_StatusDelivered_HttpStatus200()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditChannel.ApiInbound, evt.Channel);
        Assert.Equal(AuditKind.InboundRequest, evt.Kind);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        Assert.Equal(200, evt.HttpStatus);
        // C3: ForwardState is no longer a canonical field (site-storage-only);
        // central direct-write rows never carry it.
        Assert.NotEqual(Guid.Empty, evt.EventId);
        Assert.Equal("echo", evt.Target);
    }

    // ---------------------------------------------------------------------
    // 2. 400 — script/validation failure path
    // ---------------------------------------------------------------------

    [Fact]
    public async Task Pipeline_400_EmitsEvent_Status_Failed_HttpStatus400()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 400;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        // A 400 is a request the auth succeeded for — still InboundRequest, not
        // InboundAuthFailure. Only 401/403 maps to the auth-failure kind.
        Assert.Equal(AuditKind.InboundRequest, evt.Kind);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(400, evt.HttpStatus);
    }

    // ---------------------------------------------------------------------
    // 3. 401 — auth failure path
    // ---------------------------------------------------------------------

    [Fact]
    public async Task Pipeline_401_EmitsEvent_KindInboundAuthFailure_StatusFailed()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 401;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(401, evt.HttpStatus);
        // The candidate API key never resolved to a name, so Actor stays null —
        // never echo back an unauthenticated principal.
        Assert.Null(evt.Actor);
    }

    [Fact]
    public async Task Pipeline_403_EmitsEvent_KindInboundAuthFailure_StatusFailed()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 403;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(403, evt.HttpStatus);
    }

    // ---------------------------------------------------------------------
    // 4. 500 — handler threw OR returned 500
    // ---------------------------------------------------------------------

    [Fact]
    public async Task Pipeline_500_EmitsEvent_Status_Failed()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 500;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditKind.InboundRequest, evt.Kind);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal(500, evt.HttpStatus);
    }

    [Fact]
    public async Task Pipeline_Throws_EmitsEvent_Status_Failed_And_Rethrows()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var boom = new InvalidOperationException("kaboom");
        var mw = CreateMiddleware(_ => throw boom, writer);

        // The middleware MUST re-throw so the request's own error path is
        // authoritative — audit emission is best-effort only.
        var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
            () => mw.InvokeAsync(ctx));
        Assert.Same(boom, thrown);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal("kaboom", evt.ErrorMessage);
    }

    // ---------------------------------------------------------------------
    // 5. Actor resolution — the endpoint handler stashes the API key name
    //    AFTER successful auth so the middleware can pick it up from
    //    HttpContext.Items.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task ApiKeyName_Resolved_From_HttpContext_AsActor()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            // The endpoint handler is expected to stash the resolved API key
            // name here once the shared ZB.MOM.WW.Auth.ApiKeys verifier has
            // authenticated the Bearer token.
            ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal("integration-svc", evt.Actor);
    }

    // ---------------------------------------------------------------------
    // 5b. Phase 3 — Actor from the authenticated principal. When no API-key
    //     name was stashed, the actor is sourced from IAuditActorAccessor
    //     (the authenticated interactive cookie/LDAP user). The API-key stash
    //     still takes precedence, and auth-failure / no-principal paths stay
    //     null — never echo an unauthenticated principal back.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task AuthenticatedUser_FromAccessor_RecordedAsActor_WhenNoApiKeyStash()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(
            _ =>
            {
                // No API-key name stashed — this is an interactive cookie/LDAP
                // authenticated inbound user, surfaced via the accessor.
                ctx.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            actorAccessor: new StubAuditActorAccessor("alice"));

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal("alice", evt.Actor);
    }

    [Fact]
    public async Task ApiKeyStash_TakesPrecedence_OverAuthenticatedPrincipal()
    {
        // A key-authenticated caller: the endpoint handler stashed the API key
        // name. Even if an accessor would resolve a principal, the API-key
        // identity is the canonical actor for the key-authenticated path.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(
            _ =>
            {
                ctx.Items[AuditWriteMiddleware.AuditActorItemKey] = "integration-svc";
                ctx.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            actorAccessor: new StubAuditActorAccessor("should-not-win"));

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal("integration-svc", evt.Actor);
    }

    [Fact]
    public async Task AuthFailure_KeepsActorNull_EvenWhenAccessorResolvesPrincipal()
    {
        // 401/403 force the actor null BEFORE resolution — an auth failure must
        // never echo a principal back, even one the accessor could produce.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(
            _ =>
            {
                ctx.Response.StatusCode = 401;
                return Task.CompletedTask;
            },
            writer,
            actorAccessor: new StubAuditActorAccessor("attacker"));

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditKind.InboundAuthFailure, evt.Kind);
        Assert.Null(evt.Actor);
    }

    [Fact]
    public async Task NoApiKey_NoAuthenticatedPrincipal_LeavesActorNull()
    {
        // Accessor present but returns null (no authenticated interactive user)
        // and no API-key stash — the actor stays null rather than empty/echoed.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(
            _ =>
            {
                ctx.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            actorAccessor: new StubAuditActorAccessor(currentActor: null));

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.Actor);
    }

    // ---------------------------------------------------------------------
    // 6. Writer failure must NEVER alter the HTTP response
    // ---------------------------------------------------------------------

    [Fact]
    public async Task AuditWriter_Throws_HttpResponse_Unchanged_Success_Stays_Success()
    {
        var writer = new RecordingAuditWriter
        {
            OnWrite = _ => throw new InvalidOperationException("writer offline"),
        };
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        // Audit emission is best-effort; even a thrown writer must NOT bubble
        // up and contaminate the user-facing response status.
        await mw.InvokeAsync(ctx);

        Assert.Equal(200, ctx.Response.StatusCode);
    }

    [Fact]
    public async Task AuditWriter_Throws_OnFailedRequest_HttpResponse_Unchanged()
    {
        var writer = new RecordingAuditWriter
        {
            OnWrite = _ => throw new InvalidOperationException("writer offline"),
        };
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 500;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Equal(500, ctx.Response.StatusCode);
    }

    // ---------------------------------------------------------------------
    // 7. Provenance — RemoteIp + User-Agent surface in Extra JSON
    // ---------------------------------------------------------------------

    [Fact]
    public async Task RemoteIp_And_UserAgent_AppearInExtra()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext(
            userAgent: "curl/8.4.0",
            remoteIp: IPAddress.Parse("192.168.50.50"));
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.Extra);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var root = doc.RootElement;
        Assert.Equal("192.168.50.50", root.GetProperty("remoteIp").GetString());
        Assert.Equal("curl/8.4.0", root.GetProperty("userAgent").GetString());
    }

    // ---------------------------------------------------------------------
    // Body capture — the small JSON body is buffered and stashed on
    // RequestSummary so subsequent reads (the endpoint handler's
    // JsonDocument.Parse) still see the full payload.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task RequestBody_IsBuffered_AndStashed_OnRequestSummary()
    {
        var writer = new RecordingAuditWriter();
        var requestJson = "{\"x\":1}";
        var ctx = BuildContext(body: requestJson);

        string? observedAfterMiddleware = null;
        var mw = CreateMiddleware(async hc =>
        {
            // Downstream code must still be able to read the body — the
            // middleware enables buffering and rewinds so the handler sees the
            // unconsumed stream.
            using var reader = new StreamReader(hc.Request.Body);
            observedAfterMiddleware = await reader.ReadToEndAsync();
            hc.Response.StatusCode = 200;
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Equal(requestJson, observedAfterMiddleware);
        var evt = Assert.Single(writer.Events);
        Assert.Equal(requestJson, evt.RequestSummary);
    }

    // ---------------------------------------------------------------------
    // Execution id — Audit Log #23: each inbound row carries a fresh
    // per-request execution id so inbound rows are correlatable. The inbound
    // row's CorrelationId stays null — CorrelationId is purely the
    // per-operation-lifecycle id and an inbound request is a one-shot.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task InboundRow_CarriesNonNull_ExecutionId_And_NullCorrelationId()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.ExecutionId);
        Assert.NotEqual(Guid.Empty, evt.ExecutionId!.Value);
        // CorrelationId is the per-operation-lifecycle id; an inbound request
        // is a one-shot with no multi-row operation to correlate.
        Assert.Null(evt.CorrelationId);
    }

    [Fact]
    public async Task SeparateRequests_GetDistinct_ExecutionIds()
    {
        var writer = new RecordingAuditWriter();
        var mw = CreateMiddleware(hc =>
        {
            hc.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(BuildContext());
        await mw.InvokeAsync(BuildContext());

        Assert.Equal(2, writer.Events.Count);
        Assert.NotEqual(writer.Events[0].ExecutionId, writer.Events[1].ExecutionId);
    }

    // ---------------------------------------------------------------------
    // ParentExecutionId — Audit Log #23 (ParentExecutionId feature, T3): the
    // inbound request's ExecutionId is minted ONCE, early, and stashed on
    // HttpContext.Items so the endpoint handler can carry it onto the routed
    // RouteToCallRequest as ParentExecutionId. The inbound row that the
    // middleware itself emits stays top-level — its own ParentExecutionId is
    // NEVER set.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task InboundExecutionId_IsStashedOnHttpItems_BeforeEndpointRuns()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        object? observedDuringHandler = null;
        var mw = CreateMiddleware(hc =>
        {
            // The endpoint handler must be able to read the early-minted id —
            // it is stashed before _next so a downstream reader sees it.
            hc.Items.TryGetValue(AuditWriteMiddleware.InboundExecutionIdItemKey, out observedDuringHandler);
            hc.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var stashed = Assert.IsType<Guid>(observedDuringHandler);
        Assert.NotEqual(Guid.Empty, stashed);
    }

    [Fact]
    public async Task InboundRow_ExecutionId_Equals_TheEarlyMintedStashedId()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        Guid stashedDuringHandler = Guid.Empty;
        var mw = CreateMiddleware(hc =>
        {
            stashedDuringHandler =
                (Guid)hc.Items[AuditWriteMiddleware.InboundExecutionIdItemKey]!;
            hc.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        // The inbound audit row's ExecutionId must be the SAME id minted early
        // and shared with the endpoint handler — not a second, late mint.
        var evt = Assert.Single(writer.Events);
        Assert.Equal(stashedDuringHandler, evt.ExecutionId);
    }

    [Fact]
    public async Task InboundRow_OwnParentExecutionId_StaysNull()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        // The inbound request is itself top-level — only the spawn id flows
        // OUT on RouteToCallRequest. The inbound row's own ParentExecutionId
        // is never set.
        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.ParentExecutionId);
    }

    [Fact]
    public async Task DurationMs_IsRecorded()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(async _ =>
        {
            // The middleware records elapsed milliseconds — a small delay
            // ensures DurationMs is non-negative and roughly tracks reality
            // without being flake-sensitive in CI.
            await Task.Delay(5);
            ctx.Response.StatusCode = 200;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.DurationMs);
        Assert.True(evt.DurationMs >= 0);
    }

    // ---------------------------------------------------------------------
    // Response body capture — Audit Log #23 (inbound full-response feature).
    // Until the M5-deferred work landed, ResponseSummary was always null.
    // These tests pin the new contract: the middleware wraps Response.Body,
    // runs the pipeline, copies the buffered bytes back to the real stream,
    // and stashes a UTF-8 string copy on ResponseSummary.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task ResponseBody_IsCaptured_OnResponseSummary()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var responseJson = "{\"result\":42}";
        var mw = CreateMiddleware(async hc =>
        {
            hc.Response.StatusCode = 200;
            hc.Response.ContentType = "application/json";
            await hc.Response.WriteAsync(responseJson);
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(responseJson, evt.ResponseSummary);
    }

    [Fact]
    public async Task ResponseBody_IsForwardedToOriginalStream_DownstreamReadersSeeIt()
    {
        // Wrapping the response body must be TRANSPARENT — the real client
        // stream still receives every byte the pipeline wrote.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var captured = new MemoryStream();
        ctx.Response.Body = captured;            // simulate the client/test sink

        var responseJson = "{\"ok\":true}";
        var mw = CreateMiddleware(async hc =>
        {
            hc.Response.StatusCode = 200;
            await hc.Response.WriteAsync(responseJson);
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Equal(responseJson, Encoding.UTF8.GetString(captured.ToArray()));
    }

    [Fact]
    public async Task ResponseBody_Empty_LeavesResponseSummaryNull()
    {
        // No bytes written => null, not empty-string. Mirrors the request-body
        // contract in ReadBufferedRequestBodyAsync.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var mw = CreateMiddleware(hc =>
        {
            hc.Response.StatusCode = 204;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.ResponseSummary);
        Assert.Equal(204, evt.HttpStatus);
    }

    [Fact]
    public async Task ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow()
    {
        // If the handler writes some bytes then throws, the audit row still
        // surfaces whatever the framework had flushed. The middleware re-throws
        // (audit is best-effort, the request's error path stays authoritative).
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var boom = new InvalidOperationException("kaboom");
        var mw = CreateMiddleware(async hc =>
        {
            hc.Response.StatusCode = 500;
            await hc.Response.WriteAsync("partial");
            throw boom;
        }, writer);

        var thrown = await Assert.ThrowsAsync<InvalidOperationException>(
            () => mw.InvokeAsync(ctx));
        Assert.Same(boom, thrown);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(AuditStatus.Failed, evt.Status);
        Assert.Equal("partial", evt.ResponseSummary);
    }

    // ---------------------------------------------------------------------
    // Bounded audit capture — memory safety follow-up. The capture site now
    // honours AuditLogOptions.InboundMaxBytes at READ time (not just at
    // filter-time), so a 500 MiB body cannot transiently allocate 500 MiB of
    // string. The cap is local to the AUDIT copy; downstream readers and the
    // real client still see every byte.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task RequestBody_AboveInboundMaxBytes_TruncatedToCap_PayloadTruncatedTrue()
    {
        // 4 KiB cap, 20 KB body — the audit copy must be UTF-8 byte-safe
        // capped at 4 KiB AND PayloadTruncated must flip, while the
        // downstream handler still sees the full 20 KB payload.
        const int cap = 4096;
        var bigBody = new string('a', 20_000);
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext(body: bigBody);

        string? observedAfterMiddleware = null;
        var mw = CreateMiddleware(
            async hc =>
            {
                using var reader = new StreamReader(hc.Request.Body);
                observedAfterMiddleware = await reader.ReadToEndAsync();
                hc.Response.StatusCode = 200;
            },
            writer,
            options: new AuditLogOptions { InboundMaxBytes = cap });

        await mw.InvokeAsync(ctx);

        // (iii) Downstream handler still sees the FULL body — the cap applied
        // only to the audit copy.
        Assert.Equal(bigBody, observedAfterMiddleware);

        var evt = Assert.Single(writer.Events);
        // (i) Audit copy bounded at cap bytes (UTF-8 byte count).
        Assert.NotNull(evt.RequestSummary);
        Assert.True(
            Encoding.UTF8.GetByteCount(evt.RequestSummary!) <= cap,
            $"RequestSummary byte count {Encoding.UTF8.GetByteCount(evt.RequestSummary!)} exceeded cap {cap}");
        // (ii) Truncation flag set by the middleware (the filter will OR its
        // own determination on top, but the middleware MUST set it itself).
        Assert.True(evt.PayloadTruncated);
    }

    [Fact]
    public async Task ResponseBody_AboveInboundMaxBytes_TruncatedToCap_ClientStillReceivesAllBytes_PayloadTruncatedTrue()
    {
        // 4 KiB cap, 20 KB response — the test sink (acts as the real client)
        // MUST receive all 20 KB while the audit copy is bounded at 4 KiB.
        const int cap = 4096;
        var bigResponse = new string('b', 20_000);
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        var captured = new MemoryStream();
        ctx.Response.Body = captured;            // stand-in for the client sink

        var mw = CreateMiddleware(
            async hc =>
            {
                hc.Response.StatusCode = 200;
                await hc.Response.WriteAsync(bigResponse);
            },
            writer,
            options: new AuditLogOptions { InboundMaxBytes = cap });

        await mw.InvokeAsync(ctx);

        // Client sink received every byte — the forwarding wrap is transparent.
        Assert.Equal(bigResponse, Encoding.UTF8.GetString(captured.ToArray()));

        var evt = Assert.Single(writer.Events);
        // Audit copy bounded at cap bytes.
        Assert.NotNull(evt.ResponseSummary);
        Assert.True(
            Encoding.UTF8.GetByteCount(evt.ResponseSummary!) <= cap,
            $"ResponseSummary byte count {Encoding.UTF8.GetByteCount(evt.ResponseSummary!)} exceeded cap {cap}");
        Assert.True(evt.PayloadTruncated);
    }

    // ---------------------------------------------------------------------
    // InboundAPI-018: asynchronously faulted audit-write tasks must be
    // observed (logged) rather than silently dropped — but must still NOT
    // alter the user-facing HTTP response (alog.md §13).
    // ---------------------------------------------------------------------

    /// <summary>
    /// Test-only writer whose <see cref="WriteAsync"/> returns a Task that
    /// faults AFTER an asynchronous boundary, so the throw happens after
    /// <see cref="AuditWriteMiddleware"/>'s synchronous try/catch can see it —
    /// exactly the fire-and-forget bug InboundAPI-018 closes.
    /// </summary>
    private sealed class AsyncFaultingAuditWriter : ICentralAuditWriter
    {
        public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
        {
            return FaultAsync();

            static async Task FaultAsync()
            {
                // Yield off-thread so the fault surfaces ASYNCHRONOUSLY (not
                // captured by a synchronous try/catch around the WriteAsync
                // call site).
                await Task.Yield();
                throw new InvalidOperationException("async audit write failed");
            }
        }
    }

    /// <summary>
    /// Captures log entries written through a <see cref="ILogger{TCategoryName}"/>
    /// so the test can assert on the Warning that
    /// <see cref="AuditWriteMiddleware.ObserveAuditWriteFault"/> emits.
    /// </summary>
    private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<AuditWriteMiddleware>
    {
        public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();

        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

        public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;

        public void Log<TState>(
            Microsoft.Extensions.Logging.LogLevel logLevel,
            Microsoft.Extensions.Logging.EventId eventId,
            TState state,
            Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            lock (Entries)
            {
                Entries.Add((logLevel, formatter(state, exception), exception));
            }
        }

        private sealed class NullScope : IDisposable
        {
            public static readonly NullScope Instance = new();
            public void Dispose() { }
        }
    }

    [Fact]
    public async Task AuditWriter_AsyncFault_IsObserved_AsWarning_AndDoesNotAlterResponse()
    {
        var writer = new AsyncFaultingAuditWriter();
        var logger = new RecordingLogger();
        var ctx = BuildContext();

        var mw = new AuditWriteMiddleware(
            next: _ =>
            {
                ctx.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            auditWriter: writer,
            logger: logger,
            options: new StaticAuditLogOptionsMonitor(new AuditLogOptions()));

        await mw.InvokeAsync(ctx);

        // The user-facing response is untouched — audit emission is best-effort.
        Assert.Equal(200, ctx.Response.StatusCode);

        // Give the off-thread continuation a moment to fire and log. Spin
        // briefly rather than sleep-then-assert so the test is resilient to
        // scheduler jitter without inflating runtime on success.
        var deadline = DateTime.UtcNow.AddSeconds(2);
        while (DateTime.UtcNow < deadline)
        {
            lock (logger.Entries)
            {
                if (logger.Entries.Any(e =>
                        e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
                        && e.Exception is not null
                        && e.Message.Contains("async audit write faulted")))
                {
                    return;
                }
            }
            await Task.Delay(20);
        }

        // If we reach this point, the continuation did not fire — pre-fix the
        // fault would have been swallowed entirely and no log line emitted.
        var snapshot = logger.Entries.Select(e => $"{e.Level}: {e.Message}").ToList();
        Assert.Fail(
            "Expected a Warning log entry observing the async audit-write fault — none found. " +
            $"Entries: [{string.Join(", ", snapshot)}]");
    }

    // ---------------------------------------------------------------------
    // InboundAPI-019 — bodyless requests skip EnableBuffering so the
    // FileBufferingReadStream allocation is avoided on GET/HEAD/DELETE
    // and any request whose Content-Length is 0. The audit row still emits
    // with a null RequestSummary, mirroring the bodyless-POST contract.
    // ---------------------------------------------------------------------

    [Theory]
    [InlineData("GET")]
    [InlineData("HEAD")]
    [InlineData("DELETE")]
    public async Task BodylessMethod_SkipsEnableBuffering_RequestStreamIsNotReplaced(string method)
    {
        // The middleware previously called EnableBuffering on every request,
        // installing a FileBufferingReadStream wrapper even when the request
        // had no body. The bodyless-method short-circuit must leave
        // Request.Body untouched (still the original empty stream the test
        // assigns below), proving the buffering wrapper allocation is avoided.
        var writer = new RecordingAuditWriter();
        var ctx = new DefaultHttpContext();
        ctx.Request.Method = method;
        ctx.Request.Path = "/api/echo";
        ctx.Request.RouteValues["methodName"] = "echo";
        ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");

        // Distinct sentinel stream — the production code path that called
        // EnableBuffering would replace this with FileBufferingReadStream.
        // After the fix the original stream survives untouched.
        var sentinel = new MemoryStream();
        ctx.Request.Body = sentinel;

        Stream? observedDuringHandler = null;
        var mw = CreateMiddleware(hc =>
        {
            observedDuringHandler = hc.Request.Body;
            hc.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Same(sentinel, observedDuringHandler);
        var evt = Assert.Single(writer.Events);
        // No body → RequestSummary stays null, matching the bodyless-POST contract.
        Assert.Null(evt.RequestSummary);
    }

    [Fact]
    public async Task BodylessPost_ContentLengthZero_SkipsEnableBuffering()
    {
        // A POST with an explicit Content-Length of 0 is also bodyless — even
        // though POST is conventionally a body-carrying method, the explicit
        // zero short-circuits buffering. This pins the ContentLength branch of
        // the RequestHasBody guard.
        var writer = new RecordingAuditWriter();
        var ctx = new DefaultHttpContext();
        ctx.Request.Method = "POST";
        ctx.Request.Path = "/api/echo";
        ctx.Request.RouteValues["methodName"] = "echo";
        ctx.Request.ContentLength = 0;
        ctx.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.5");

        var sentinel = new MemoryStream();
        ctx.Request.Body = sentinel;

        Stream? observedDuringHandler = null;
        var mw = CreateMiddleware(hc =>
        {
            observedDuringHandler = hc.Request.Body;
            hc.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Same(sentinel, observedDuringHandler);
        var evt = Assert.Single(writer.Events);
        Assert.Null(evt.RequestSummary);
    }

    [Fact]
    public async Task PostWithBody_StillEnablesBuffering_AndCapturesRequestSummary()
    {
        // Regression: the bodyless short-circuit must NOT regress the existing
        // body-capture contract for normal POSTs — we still need to buffer +
        // capture the request body for the audit row.
        var writer = new RecordingAuditWriter();
        var requestJson = "{\"a\":42}";
        var ctx = BuildContext(body: requestJson);

        string? observedAfterMiddleware = null;
        var mw = CreateMiddleware(async hc =>
        {
            using var reader = new StreamReader(hc.Request.Body);
            observedAfterMiddleware = await reader.ReadToEndAsync();
            hc.Response.StatusCode = 200;
        }, writer);

        await mw.InvokeAsync(ctx);

        Assert.Equal(requestJson, observedAfterMiddleware);
        var evt = Assert.Single(writer.Events);
        Assert.Equal(requestJson, evt.RequestSummary);
    }

    // ---------------------------------------------------------------------
    // M5.3 (T7) Increment 1: Request headers in Extra JSON
    // Request headers are captured into the Extra JSON object alongside the
    // existing remoteIp / userAgent fields. Sensitive headers (e.g.
    // Authorization, X-Api-Key) are redacted to "<redacted>" using the same
    // HeaderRedactList as ScadaBridgeAuditRedactor.
    // ---------------------------------------------------------------------

    [Fact]
    public async Task RequestHeaders_AppearInExtra_UnderRequestHeadersKey()
    {
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        ctx.Request.Headers["X-Custom-Header"] = "custom-value";

        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.Extra);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var root = doc.RootElement;
        // Extra must carry a requestHeaders object.
        Assert.True(root.TryGetProperty("requestHeaders", out var headers),
            "Extra JSON must contain a 'requestHeaders' property");
        Assert.Equal(JsonValueKind.Object, headers.ValueKind);
        // The non-sensitive custom header must appear unredacted.
        Assert.True(headers.TryGetProperty("X-Custom-Header", out var customVal),
            "requestHeaders must contain 'X-Custom-Header'");
        Assert.Equal("custom-value", customVal.GetString());
    }

    [Fact]
    public async Task RequestHeaders_AuthorizationHeader_IsRedacted()
    {
        // Authorization is in the default HeaderRedactList and must appear as
        // "<redacted>" rather than the real token value.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        ctx.Request.Headers["Authorization"] = "Bearer secret-token-abc";

        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.Extra);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var root = doc.RootElement;
        var headers = root.GetProperty("requestHeaders");
        Assert.True(headers.TryGetProperty("Authorization", out var authVal),
            "requestHeaders must contain 'Authorization'");
        Assert.Equal("<redacted>", authVal.GetString());
    }

    [Fact]
    public async Task RequestHeaders_XApiKeyHeader_IsRedacted()
    {
        // X-Api-Key is in the default HeaderRedactList and must be redacted.
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        ctx.Request.Headers["X-Api-Key"] = "sbk_12345_secretkey";

        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.NotNull(evt.Extra);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var root = doc.RootElement;
        var headers = root.GetProperty("requestHeaders");
        Assert.True(headers.TryGetProperty("X-Api-Key", out var keyVal));
        Assert.Equal("<redacted>", keyVal.GetString());
    }

    [Fact]
    public async Task RequestHeaders_CustomRedactListEntry_IsRedacted()
    {
        // A non-default entry added to HeaderRedactList must also be redacted.
        var opts = new AuditLogOptions
        {
            HeaderRedactList = new List<string>
            {
                "Authorization", "X-Api-Key", "Cookie", "Set-Cookie",
                "X-Internal-Secret", // custom addition
            },
        };
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        ctx.Request.Headers["X-Internal-Secret"] = "my-secret-value";
        ctx.Request.Headers["X-Safe-Header"] = "safe-value";

        var mw = CreateMiddleware(
            _ =>
            {
                ctx.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            options: opts);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var headers = doc.RootElement.GetProperty("requestHeaders");
        Assert.Equal("<redacted>", headers.GetProperty("X-Internal-Secret").GetString());
        Assert.Equal("safe-value", headers.GetProperty("X-Safe-Header").GetString());
    }

    [Fact]
    public async Task RequestHeaders_Redaction_IsCaseInsensitive()
    {
        // HeaderRedactList match must be case-insensitive (mirrors the
        // ScadaBridgeAuditRedactor behaviour — the redact set uses
        // OrdinalIgnoreCase).
        var writer = new RecordingAuditWriter();
        var ctx = BuildContext();
        // Vary the casing from the list entry ("Authorization").
        ctx.Request.Headers["authorization"] = "Bearer lower-case-token";

        var mw = CreateMiddleware(_ =>
        {
            ctx.Response.StatusCode = 200;
            return Task.CompletedTask;
        }, writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        using var doc = JsonDocument.Parse(evt.Extra!);
        var headers = doc.RootElement.GetProperty("requestHeaders");
        // ASP.NET Core normalises the header name to "authorization" in the dict;
        // the redact set (OrdinalIgnoreCase) must still match it.
        Assert.Equal("<redacted>", headers.GetProperty("authorization").GetString());
    }

    // ---------------------------------------------------------------------
    // M5.3 (T7) Increment 2: AuditInboundCeilingHits counter
    // When request OR response exceeds InboundMaxBytes, the middleware
    // increments IAuditInboundCeilingHitsCounter once per request.
    // ---------------------------------------------------------------------

    /// <summary>
    /// In-memory <see cref="IAuditInboundCeilingHitsCounter"/> that records
    /// every <see cref="Increment"/> call.
    /// </summary>
    private sealed class RecordingCeilingHitsCounter : ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter
    {
        private int _count;
        public int Count => Volatile.Read(ref _count);
        public void Increment() => Interlocked.Increment(ref _count);
    }

    private static AuditWriteMiddleware CreateMiddlewareWithCounter(
        RequestDelegate next,
        ICentralAuditWriter writer,
        AuditLogOptions? options,
        ZB.MOM.WW.ScadaBridge.AuditLog.Central.IAuditInboundCeilingHitsCounter counter) =>
        new(
            next,
            writer,
            NullLogger<AuditWriteMiddleware>.Instance,
            new StaticAuditLogOptionsMonitor(options ?? new AuditLogOptions()),
            actorAccessor: null,
            ceilingHitsCounter: counter);

    [Fact]
    public async Task RequestBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
    {
        const int cap = 1024;
        var bigBody = new string('x', cap + 100);
        var writer = new RecordingAuditWriter();
        var counter = new RecordingCeilingHitsCounter();
        var ctx = BuildContext(body: bigBody);
        var mw = CreateMiddlewareWithCounter(
            hc =>
            {
                hc.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            options: new AuditLogOptions { InboundMaxBytes = cap },
            counter: counter);

        await mw.InvokeAsync(ctx);

        Assert.Equal(1, counter.Count);
        // Verify the truncation did happen to confirm ceiling was hit.
        var evt = Assert.Single(writer.Events);
        Assert.True(evt.PayloadTruncated);
    }

    [Fact]
    public async Task ResponseBody_AboveInboundMaxBytes_IncrementsCeilingHitsCounter()
    {
        const int cap = 1024;
        var bigResponse = new string('y', cap + 100);
        var writer = new RecordingAuditWriter();
        var counter = new RecordingCeilingHitsCounter();
        var ctx = BuildContext();
        ctx.Response.Body = new MemoryStream();

        var mw = CreateMiddlewareWithCounter(
            async hc =>
            {
                hc.Response.StatusCode = 200;
                await hc.Response.WriteAsync(bigResponse);
            },
            writer,
            options: new AuditLogOptions { InboundMaxBytes = cap },
            counter: counter);

        await mw.InvokeAsync(ctx);

        Assert.Equal(1, counter.Count);
        var evt = Assert.Single(writer.Events);
        Assert.True(evt.PayloadTruncated);
    }

    [Fact]
    public async Task NormalRequest_WithinCap_DoesNotIncrementCeilingHitsCounter()
    {
        var writer = new RecordingAuditWriter();
        var counter = new RecordingCeilingHitsCounter();
        var smallBody = "{\"ok\":true}";
        var ctx = BuildContext(body: smallBody);
        // Cap is well above the body size.
        var mw = CreateMiddlewareWithCounter(
            hc =>
            {
                hc.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            options: new AuditLogOptions { InboundMaxBytes = 8192 },
            counter: counter);

        await mw.InvokeAsync(ctx);

        Assert.Equal(0, counter.Count);
    }

    // ---------------------------------------------------------------------
    // M5.3 (T7) Increment 3: SkipBodyCapture per-method opt-out
    // A target with SkipBodyCapture=true produces an audit row with
    // headers/metadata but empty/omitted body. A normal target still captures.
    // ---------------------------------------------------------------------

    private static DefaultHttpContext BuildContextWithRoute(
        string methodName,
        string? body = null)
    {
        var ctx = new DefaultHttpContext();
        ctx.Request.Method = "POST";
        ctx.Request.Path = $"/api/{methodName}";
        ctx.Request.RouteValues["methodName"] = methodName;
        ctx.Connection.RemoteIpAddress = System.Net.IPAddress.Parse("10.0.0.1");

        if (body is not null)
        {
            var bytes = Encoding.UTF8.GetBytes(body);
            ctx.Request.Body = new MemoryStream(bytes);
            ctx.Request.ContentLength = bytes.Length;
            ctx.Request.ContentType = "application/json";
        }

        return ctx;
    }

    [Fact]
    public async Task SkipBodyCapture_True_AuditRowEmitted_ButBodyIsNull()
    {
        // A target with SkipBodyCapture=true must produce an audit row (the
        // row must not be suppressed entirely) but RequestSummary and
        // ResponseSummary must both be null — only the body is omitted.
        var writer = new RecordingAuditWriter();
        var opts = new AuditLogOptions
        {
            PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
            {
                ["secret-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
                {
                    SkipBodyCapture = true,
                },
            },
        };
        var ctx = BuildContextWithRoute("secret-method", body: "{\"sensitive\":\"data\"}");

        var mw = CreateMiddleware(
            async hc =>
            {
                hc.Response.StatusCode = 200;
                await hc.Response.WriteAsync("{\"result\":\"secret\"}");
            },
            writer,
            options: opts);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        // Row IS emitted — only the body content is suppressed.
        Assert.Equal("secret-method", evt.Target);
        Assert.Equal(AuditStatus.Delivered, evt.Status);
        // Bodies are null — SkipBodyCapture stripped them.
        Assert.Null(evt.RequestSummary);
        Assert.Null(evt.ResponseSummary);
        // Headers / metadata are still present.
        Assert.NotNull(evt.Extra);
        using var doc = JsonDocument.Parse(evt.Extra!);
        Assert.True(doc.RootElement.TryGetProperty("requestHeaders", out _),
            "Headers must be present even when body capture is skipped");
        Assert.Equal(200, evt.HttpStatus);
    }

    [Fact]
    public async Task SkipBodyCapture_True_CeilingHitsCounter_NotIncremented()
    {
        // When SkipBodyCapture=true the body is never measured against the cap;
        // the counter must NOT be bumped even if the body would have exceeded it.
        var writer = new RecordingAuditWriter();
        var counter = new RecordingCeilingHitsCounter();
        const int cap = 64;
        var bigBody = new string('z', cap + 1000);
        var opts = new AuditLogOptions
        {
            InboundMaxBytes = cap,
            PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
            {
                ["large-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
                {
                    SkipBodyCapture = true,
                },
            },
        };
        var ctx = BuildContextWithRoute("large-method", body: bigBody);

        var mw = CreateMiddlewareWithCounter(
            hc =>
            {
                hc.Response.StatusCode = 200;
                return Task.CompletedTask;
            },
            writer,
            options: opts,
            counter: counter);

        await mw.InvokeAsync(ctx);

        Assert.Equal(0, counter.Count);
    }

    [Fact]
    public async Task SkipBodyCapture_False_NormalTarget_StillCapturesBody()
    {
        // Regression: a target WITHOUT SkipBodyCapture (or with SkipBodyCapture=false)
        // must still capture the body normally.
        var writer = new RecordingAuditWriter();
        var opts = new AuditLogOptions
        {
            PerTargetOverrides = new Dictionary<string, ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride>
            {
                ["normal-method"] = new ZB.MOM.WW.ScadaBridge.AuditLog.Configuration.PerTargetRedactionOverride
                {
                    SkipBodyCapture = false,
                },
            },
        };
        var requestJson = "{\"a\":1}";
        var ctx = BuildContextWithRoute("normal-method", body: requestJson);

        var mw = CreateMiddleware(
            async hc =>
            {
                hc.Response.StatusCode = 200;
                await hc.Response.WriteAsync("{\"result\":1}");
            },
            writer,
            options: opts);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(requestJson, evt.RequestSummary);
        Assert.Equal("{\"result\":1}", evt.ResponseSummary);
    }

    [Fact]
    public async Task SkipBodyCapture_NoOverride_DefaultTarget_StillCapturesBody()
    {
        // A target with no per-target override at all must still capture the body —
        // SkipBodyCapture defaults to false and must not suppress capture.
        var writer = new RecordingAuditWriter();
        var requestJson = "{\"x\":99}";
        var ctx = BuildContext(body: requestJson);

        var mw = CreateMiddleware(
            async hc =>
            {
                hc.Response.StatusCode = 200;
                await hc.Response.WriteAsync("{\"y\":99}");
            },
            writer);

        await mw.InvokeAsync(ctx);

        var evt = Assert.Single(writer.Events);
        Assert.Equal(requestJson, evt.RequestSummary);
        Assert.Equal("{\"y\":99}", evt.ResponseSummary);
    }
}
