using System.Buffers;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
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;

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

/// <summary>
/// Emits one <see cref="AuditChannel.ApiInbound"/>
/// row per inbound API request via <see cref="ICentralAuditWriter"/> covering the
/// full set of response shapes:
///
/// <list type="bullet">
/// <item><description>2xx / non-error → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Delivered"/>.</description></item>
/// <item><description>401/403 → <see cref="AuditKind.InboundAuthFailure"/> with <see cref="AuditStatus.Failed"/>.</description></item>
/// <item><description>4xx (non-auth) / 5xx / thrown exception → <see cref="AuditKind.InboundRequest"/> with <see cref="AuditStatus.Failed"/>.</description></item>
/// </list>
///
/// <para>
/// <b>Best-effort contract (alog.md §13).</b> Audit emission NEVER alters the
/// user-facing HTTP response — a thrown writer or any other failure during
/// emission is caught, logged at warning, and dropped. A handler exception is
/// recorded on the audit row then re-thrown so the framework error path stays
/// authoritative.
/// </para>
///
/// <para>
/// <b>Actor resolution.</b> The API-key auth path runs inside the endpoint handler
/// (no <c>UseAuthentication</c>-backed scheme populates <see cref="HttpContext.User"/>
/// for Bearer API-key callers), so the handler stashes the resolved API key name on
/// <see cref="HttpContext.Items"/> under <see cref="AuditActorItemKey"/> after
/// <c>IApiKeyVerifier.VerifyAsync</c> succeeds. The middleware reads it in
/// its <c>finally</c> block. When no API-key name is stashed, the actor is
/// sourced from the authenticated <em>interactive</em> principal via
/// <see cref="IAuditActorAccessor"/> (a cookie/LDAP-authenticated inbound user,
/// keyed off the canonical username claim). On auth failures (401/403) the actor is
/// forced null before resolution runs, and the accessor itself returns null for an
/// unauthenticated principal — so <see cref="AuditEvent.Actor"/> stays null and we
/// never echo back an unauthenticated principal.
/// </para>
///
/// <para>
/// <b>Body capture.</b> The request body is buffered via
/// <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/> then
/// rewound so the downstream endpoint handler still sees the full payload. The
/// response body is captured by wrapping <see cref="HttpResponse.Body"/> in a
/// forwarding stream that mirrors writes to the original sink (transparent to
/// the real client) while capturing a bounded copy for audit.
/// </para>
///
/// <para>
/// <b>Bounded capture at the source.</b> Both the request- and response-body
/// audit copies are bounded at <see cref="AuditLogOptions.InboundMaxBytes"/>
/// (default 1 MiB) AT THE CAPTURE SITE — we never buffer more than
/// <c>cap + 1</c> bytes per body even when the client streams hundreds of MiB.
/// The downstream handler and the real client still see every byte; only the
/// audit copy is bounded. The cap is also enforced again by
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Redaction.ScadaBridgeAuditRedactor"/> (which OR's
/// in its own <see cref="AuditEvent.PayloadTruncated"/> determination), so a
/// row truncated here remains truncated even if the redactor is bypassed.
/// </para>
/// </summary>
public sealed class AuditWriteMiddleware
{
    /// <summary>
    /// <see cref="HttpContext.Items"/> key used by the endpoint handler to publish
    /// the resolved API key name once <c>IApiKeyVerifier.VerifyAsync</c> has
    /// succeeded. Exposed as a constant so the handler and middleware share a
    /// single source of truth (no stringly-typed coupling).
    /// </summary>
    public const string AuditActorItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.AuditActor";

    /// <summary>
    /// ParentExecutionId: <see cref="HttpContext.Items"/> key under
    /// which this middleware stashes the inbound request's per-request
    /// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
    /// The id is minted ONCE and shared: the endpoint handler reads it to thread it
    /// onto a routed <c>RouteToCallRequest.ParentExecutionId</c>, and the
    /// middleware's own inbound audit row uses the same id for its
    /// <see cref="AuditEvent.ExecutionId"/>. Exposed as a constant so the handler
    /// and middleware share a single source of truth (no stringly-typed coupling).
    /// </summary>
    public const string InboundExecutionIdItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.InboundExecutionId";

    private readonly RequestDelegate _next;
    private readonly ICentralAuditWriter _auditWriter;
    private readonly ILogger<AuditWriteMiddleware> _logger;
    private readonly IOptionsMonitor<AuditLogOptions> _options;
    private readonly IAuditActorAccessor? _actorAccessor;
    private readonly IAuditInboundCeilingHitsCounter _ceilingHitsCounter;

    /// <summary>
    /// Initializes the middleware with its required dependencies.
    /// </summary>
    /// <param name="next">The next middleware in the pipeline.</param>
    /// <param name="auditWriter">Central audit writer used to persist inbound API audit events.</param>
    /// <param name="logger">Logger for this middleware.</param>
    /// <param name="options">Live-reloadable audit log options, read per-request.</param>
    /// <param name="actorAccessor">
    /// Resolves the audit <see cref="AuditEvent.Actor"/> from the
    /// authenticated principal on a cookie/LDAP-authenticated inbound request. Optional
    /// so existing tests (and any composition without the accessor registered) still
    /// construct the middleware; when absent, actor resolution falls back to the
    /// stashed API-key name only.
    /// </param>
    /// <param name="ceilingHitsCounter">
    /// Incremented whenever an inbound request or response
    /// body is truncated at <see cref="AuditLogOptions.InboundMaxBytes"/>. Optional
    /// so existing tests and composition roots without the central health snapshot
    /// wired still construct without the counter; a NoOp is used when absent.
    /// </param>
    public AuditWriteMiddleware(
        RequestDelegate next,
        ICentralAuditWriter auditWriter,
        ILogger<AuditWriteMiddleware> logger,
        IOptionsMonitor<AuditLogOptions> options,
        IAuditActorAccessor? actorAccessor = null,
        IAuditInboundCeilingHitsCounter? ceilingHitsCounter = null)
    {
        _next = next ?? throw new ArgumentNullException(nameof(next));
        _auditWriter = auditWriter ?? throw new ArgumentNullException(nameof(auditWriter));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _options = options ?? throw new ArgumentNullException(nameof(options));
        _actorAccessor = actorAccessor;
        _ceilingHitsCounter = ceilingHitsCounter ?? new NoOpAuditInboundCeilingHitsCounter();
    }

    /// <summary>
    /// Executes the middleware: captures the request/response bodies and writes an inbound API audit event.
    /// </summary>
    /// <param name="ctx">The current HTTP context.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task InvokeAsync(HttpContext ctx)
    {
        var sw = Stopwatch.StartNew();

        // Per-request hot read of the options snapshot so a live config change
        // picks up on the next request without re-resolving the singleton.
        // InboundMaxBytes is read once here and passed to the capture helpers.
        var opts = _options.CurrentValue;
        var cap = opts.InboundMaxBytes;

        // ParentExecutionId: mint the inbound request's per-request
        // ExecutionId ONCE, here at the start of the request, and stash it on
        // HttpContext.Items. Two consumers share this single id:
        //   (a) the endpoint handler reads it to thread onto a routed
        //       RouteToCallRequest.ParentExecutionId, so a spawned site script
        //       execution points back at this inbound request;
        //   (b) the inbound audit row this middleware emits uses it as its own
        //       ExecutionId (the row stays top-level — its ParentExecutionId is
        //       never set).
        ctx.Items[InboundExecutionIdItemKey] = Guid.NewGuid();

        // Buffer the request body up front so we can both audit it and let the
        // downstream handler still parse it. EnableBuffering swaps the request
        // stream for a seekable wrapper that the framework rewinds at the end
        // of the pipeline for us — but we also rewind to position 0 after our
        // own read so the very next reader starts from the top.
        //
        // Skip EnableBuffering for bodyless requests (a known
        // empty Content-Length or a method that conventionally carries no body —
        // GET / HEAD / DELETE / TRACE / OPTIONS). The FileBufferingReadStream
        // wrapper EnableBuffering installs allocates an internal buffer regardless
        // of whether the request actually has a body; bodyless inbound traffic
        // (e.g. GET /api/audit/query, health probes) no longer pays that cost.
        // ReadBufferedRequestBodyAsync's own ContentLength is 0 short-circuit
        // returns (null, false) for the bodyless case anyway, so the audit row
        // is unchanged.
        //
        // Check if the matched method/target has SkipBodyCapture set.
        // The route value is resolved BEFORE the pipeline runs (route matching
        // has already bound {methodName} at this point), so we can skip the
        // EnableBuffering allocation and body read up front.
        var methodNameForOverride = ctx.Request.RouteValues.TryGetValue("methodName", out var rv)
            && rv is string mn && !string.IsNullOrWhiteSpace(mn) ? mn : null;
        var skipBody = methodNameForOverride != null
            && opts.PerTargetOverrides.TryGetValue(methodNameForOverride, out var perTarget)
            && perTarget.SkipBodyCapture;

        var requestBody = (string?)null;
        var requestTruncated = false;
        if (!skipBody && RequestHasBody(ctx.Request))
        {
            ctx.Request.EnableBuffering();
            (requestBody, requestTruncated) =
                await ReadBufferedRequestBodyAsync(ctx.Request, cap).ConfigureAwait(false);
        }

        // Response body — wrap Response.Body in a forwarding stream that mirrors
        // every write to the original sink (transparent to the real client)
        // while capturing AT MOST `cap + 1` bytes for the audit copy. The
        // original Response.Body is restored in the finally block.
        var originalResponseBody = ctx.Response.Body;
        using var captureStream = new CapturedResponseStream(originalResponseBody, cap);
        ctx.Response.Body = captureStream;

        Exception? thrown = null;
        try
        {
            await _next(ctx).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            thrown = ex;
            // Re-throw — audit emission is BEST EFFORT, but the user-facing
            // request's own error path must remain authoritative (alog.md §13).
            throw;
        }
        finally
        {
            sw.Stop();

            // Restore the original stream and resolve the captured audit copy.
            // The forwarding wrapper has already written every byte to the
            // original sink; this just pulls back the bounded UTF-8 string.
            ctx.Response.Body = originalResponseBody;
            var (capturedResponseBody, capturedResponseTruncated) = captureStream.GetCapturedBody();
            // If SkipBodyCapture is set, discard the captured response
            // body (the request body was never captured above). The row + headers
            // still emit with null RequestSummary / ResponseSummary.
            // Truncation flags are also cleared so ceiling-hit counter is not
            // bumped for methods that deliberately opt out of body capture.
            var responseBody = skipBody ? null : capturedResponseBody;
            var responseTruncated = skipBody ? false : capturedResponseTruncated;

            EmitInboundAudit(
                ctx,
                opts,
                sw.ElapsedMilliseconds,
                thrown,
                requestBody,
                responseBody,
                requestTruncated || responseTruncated,
                requestTruncated,
                responseTruncated);
        }
    }

    /// <summary>
    /// Builds and writes the <see cref="AuditChannel.ApiInbound"/> row for the
    /// request. Wrapped in try/catch so a thrown writer or any other emission
    /// failure stays out of the user-facing response (alog.md §13).
    /// </summary>
    private void EmitInboundAudit(
        HttpContext ctx,
        AuditLogOptions opts,
        long durationMs,
        Exception? thrown,
        string? requestBody,
        string? responseBody,
        bool payloadTruncated,
        bool requestTruncated = false,
        bool responseTruncated = false)
    {
        try
        {
            var statusCode = ctx.Response.StatusCode;
            var isAuthFailure = statusCode is 401 or 403;

            var kind = isAuthFailure
                ? AuditKind.InboundAuthFailure
                : AuditKind.InboundRequest;

            // A thrown handler exception is always Failed; otherwise any 4xx/5xx
            // response signals failure. 2xx/3xx are Delivered.
            var status = (thrown != null || statusCode >= 400)
                ? AuditStatus.Failed
                : AuditStatus.Delivered;

            var actor = isAuthFailure ? null : ResolveActor(ctx);
            var methodName = ResolveMethodName(ctx);

            // Increment the ceiling-hits counter once per request
            // that hit the cap on EITHER the request or response body.
            if (requestTruncated || responseTruncated)
            {
                try { _ceilingHitsCounter.Increment(); } catch { /* swallow per §7 */ }
            }

            // Capture request headers into Extra JSON alongside the
            // existing remoteIp / userAgent provenance fields. The header
            // collection is run through the SAME header-redaction list
            // (AuditLogOptions.HeaderRedactList) that the ScadaBridgeAuditRedactor
            // applies to RequestSummary / ResponseSummary — auth/sensitive
            // headers are redacted before they land in the row. Uses the SAME
            // options snapshot captured at request start (passed in as opts) as
            // the SkipBodyCapture / PerTargetOverrides decisions, so a mid-request
            // live-reload can't split the body-capture and header-redaction
            // verdicts across two different snapshots.
            var redactSet = new HashSet<string>(
                opts.HeaderRedactList,
                StringComparer.OrdinalIgnoreCase);

            var headerDict = new Dictionary<string, string>(StringComparer.Ordinal);
            foreach (var header in ctx.Request.Headers)
            {
                // Redact headers whose name appears in the HeaderRedactList —
                // the same "<redacted>" marker used by ScadaBridgeAuditRedactor.
                var value = redactSet.Contains(header.Key)
                    ? "<redacted>"
                    : header.Value.ToString();
                headerDict[header.Key] = value;
            }

            var extra = JsonSerializer.Serialize(new
            {
                remoteIp = ctx.Connection.RemoteIpAddress?.ToString(),
                userAgent = ctx.Request.Headers.UserAgent.ToString(),
                requestHeaders = headerDict,
            });

            var evt = ScadaBridgeAuditEventFactory.Create(
                channel: AuditChannel.ApiInbound,
                kind: kind,
                status: status,
                occurredAtUtc: DateTime.UtcNow,
                actor: actor,
                target: methodName,
                // The per-request execution id minted ONCE at the
                // start of the request (InvokeAsync) and stashed on
                // HttpContext.Items. The same id is threaded onto a routed
                // RouteToCallRequest.ParentExecutionId by the endpoint handler,
                // so an inbound request and the site script it routes to share
                // one correlation point. This inbound row stays top-level — its
                // own ParentExecutionId is never set.
                executionId: ResolveInboundExecutionId(ctx),
                // CorrelationId is purely the per-operation-lifecycle id; an
                // inbound request is a one-shot from the audit row's
                // perspective with no multi-row operation to correlate.
                correlationId: null,
                httpStatus: statusCode,
                durationMs: (int)Math.Min(durationMs, int.MaxValue),
                errorMessage: thrown?.Message,
                requestSummary: requestBody,
                responseSummary: responseBody,
                payloadTruncated: payloadTruncated,
                extra: extra);
            // Central direct-write — no site-local forwarding state (not a
            // canonical field).

            // Fire-and-forget the writer so the user-facing
            // response stays non-blocking (alog.md §13 — audit emission must
            // NEVER abort or delay the user request), but observe the returned
            // Task so an asynchronous fault is logged instead of vanishing into
            // TaskScheduler.UnobservedTaskException. The outer try/catch still
            // catches a synchronous throw before WriteAsync returns a task; the
            // ContinueWith only fires on a faulted task and runs off-thread, so
            // it does not block the response.
            var writeTask = _auditWriter.WriteAsync(evt);
            ObserveAuditWriteFault(writeTask, ctx);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(
                ex,
                "AuditWriteMiddleware emission failed for {Method} {Path} (status {Status})",
                ctx.Request.Method, ctx.Request.Path, ctx.Response.StatusCode);
        }
    }

    /// <summary>
    /// Observe the audit writer's returned <see cref="Task"/>
    /// so a fault that surfaces ASYNCHRONOUSLY (e.g. a DB timeout deep in the
    /// central audit pipeline) is logged at Warning rather than dropped into
    /// <see cref="TaskScheduler.UnobservedTaskException"/>. Stays
    /// fire-and-forget so the user-facing response is not delayed — the
    /// continuation runs only on a faulted task and writes a single log line
    /// off the request thread. A completed-successfully task takes the fast
    /// path with no continuation scheduled.
    /// </summary>
    private void ObserveAuditWriteFault(Task writeTask, HttpContext ctx)
    {
        if (writeTask.IsCompletedSuccessfully)
        {
            return;
        }

        var method = ctx.Request.Method;
        var path = ctx.Request.Path;
        var status = ctx.Response.StatusCode;
        _ = writeTask.ContinueWith(
            t => _logger.LogWarning(
                t.Exception,
                "AuditWriteMiddleware async audit write faulted for {Method} {Path} (status {Status})",
                method, path, status),
            CancellationToken.None,
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
            TaskScheduler.Default);
    }

    /// <summary>
    /// Decides whether the request is likely to carry a body, so the
    /// caller can skip <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/>
    /// (and the associated <c>FileBufferingReadStream</c> allocation) on requests that
    /// definitely won't have one. Returns <c>true</c> when <see cref="HttpRequest.ContentLength"/>
    /// is positive OR when the HTTP method is one that conventionally carries a body
    /// (POST / PUT / PATCH). Bodyless methods (GET / HEAD / DELETE / TRACE / OPTIONS)
    /// with an absent or zero Content-Length return <c>false</c> — those are the
    /// requests that previously paid the buffering allocation for no benefit. A
    /// body-carrying method with no Content-Length (e.g. chunked transfer-encoding)
    /// still buffers, so streamed POST bodies are unaffected.
    /// </summary>
    private static bool RequestHasBody(HttpRequest request)
    {
        if (request.ContentLength is > 0)
        {
            return true;
        }

        var method = request.Method;
        return HttpMethods.IsPost(method)
            || HttpMethods.IsPut(method)
            || HttpMethods.IsPatch(method);
    }

    /// <summary>
    /// Reads the buffered request body up to <paramref name="capBytes"/> bytes
    /// into a string for the audit copy and rewinds the stream so the
    /// downstream handler sees the unconsumed payload. Returns
    /// <c>(null, false)</c> for empty/missing bodies so the audit row's
    /// <see cref="AuditEvent.RequestSummary"/> stays null rather than
    /// containing an empty string.
    /// </summary>
    /// <remarks>
    /// Reads AT MOST <c>cap + 1</c> bytes from the request stream into a
    /// scratch buffer; if the extra byte arrives the body is over the cap and
    /// the returned string is UTF-8 byte-safe truncated to exactly
    /// <c>cap</c> bytes with <c>truncated = true</c>. The cap applies only to
    /// the audit copy — the request stream is always rewound to position 0
    /// afterwards so the framework's next reader (the endpoint handler's
    /// JSON parser) sees the full body.
    /// </remarks>
    private static async Task<(string? body, bool truncated)> ReadBufferedRequestBodyAsync(
        HttpRequest request,
        int capBytes)
    {
        if (request.ContentLength is 0)
        {
            return (null, false);
        }

        // Read AT MOST cap + 1 bytes — the extra byte tells us the body was
        // over the cap without forcing us to allocate the whole payload. Rent
        // the scratch buffer from the shared ArrayPool so we don't allocate
        // (and immediately discard) `cap + 1` bytes per request — the pool
        // may hand back a buffer LARGER than `limit`, so we treat `limit`
        // (not `buffer.Length`) as the read ceiling.
        var limit = capBytes + 1;
        var buffer = ArrayPool<byte>.Shared.Rent(limit);
        try
        {
            request.Body.Position = 0;

            var total = 0;
            while (total < limit)
            {
                var read = await request.Body
                    .ReadAsync(buffer.AsMemory(total, limit - total))
                    .ConfigureAwait(false);
                if (read == 0)
                {
                    break;
                }
                total += read;
            }

            if (total == 0)
            {
                return (null, false);
            }

            var truncated = total > capBytes;
            var bytesForString = truncated ? capBytes : total;
            var content = DecodeUtf8Bounded(buffer, bytesForString, cutAtValidBytes: truncated);
            return (string.IsNullOrEmpty(content) ? null : content, truncated);
        }
        catch
        {
            // A failed body read must not abort the request — fall through
            // with a null RequestSummary; the audit row still records the
            // outcome.
            return (null, false);
        }
        finally
        {
            // Even on a thrown read, the downstream handler must see the full
            // body from position 0 — never let a failed audit copy leak a
            // truncated view. A rewind failure is swallowed: best-effort,
            // same philosophy as the rest of the file.
            try { request.Body.Position = 0; } catch { /* swallow */ }
            ArrayPool<byte>.Shared.Return(buffer);
        }
    }

    /// <summary>
    /// UTF-8 byte-safe decode of <paramref name="validBytes"/> bytes from
    /// <paramref name="bytes"/>. When <paramref name="cutAtValidBytes"/> is
    /// <c>true</c> the input is the result of a hard byte-count truncation, so
    /// we walk back from <c>validBytes</c> while the byte is a continuation
    /// byte (<c>byte &amp; 0xC0 == 0x80</c>) to avoid splitting a multi-byte
    /// codepoint. When <c>false</c> the caller is decoding the full payload
    /// and the boundary stands as-is.
    /// </summary>
    /// <remarks>
    /// Mirrors the algorithm in <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Payload.AuditRedactionPrimitives.TruncateUtf8"/>;
    /// kept local to avoid a backwards project reference from
    /// ZB.MOM.WW.ScadaBridge.AuditLog into ZB.MOM.WW.ScadaBridge.InboundAPI.
    /// </remarks>
    private static string DecodeUtf8Bounded(byte[] bytes, int validBytes, bool cutAtValidBytes)
    {
        if (validBytes <= 0)
        {
            return string.Empty;
        }
        var boundary = validBytes;
        if (cutAtValidBytes)
        {
            while (boundary > 0 && (bytes[boundary] & 0xC0) == 0x80)
            {
                boundary--;
            }
        }
        return Encoding.UTF8.GetString(bytes, 0, boundary);
    }

    /// <summary>
    /// ParentExecutionId: reads the inbound request's per-request
    /// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
    /// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
    /// Throws <see cref="InvalidOperationException"/> if the slot is absent — for a
    /// correlation feature a silently-divergent id is the worst failure mode, so we
    /// fail fast rather than mint a fresh one. <see cref="EmitInboundAudit"/>'s
    /// try/catch degrades the throw to a dropped best-effort audit row, never a
    /// failed request.
    /// </summary>
    private static Guid ResolveInboundExecutionId(HttpContext ctx)
    {
        if (ctx.Items.TryGetValue(InboundExecutionIdItemKey, out var stashed)
            && stashed is Guid id)
        {
            return id;
        }

        throw new InvalidOperationException(
            "Inbound ExecutionId invariant violated: the inbound ExecutionId must be "
            + "stashed by AuditWriteMiddleware.InvokeAsync before the audit row is emitted.");
    }

    /// <summary>
    /// Resolves the audit <see cref="AuditEvent.Actor"/> for a non-auth-failure
    /// inbound request:
    /// <list type="number">
    /// <item><description>the API key name the endpoint handler stashed on
    /// <see cref="HttpContext.Items"/> after successful key auth (the
    /// key-authenticated path — the canonical identity of an API-key caller);</description></item>
    /// <item><description>otherwise the authenticated <em>interactive</em> principal
    /// resolved through <see cref="IAuditActorAccessor"/> (a
    /// cookie/LDAP-authenticated inbound user, sourced from the canonical username
    /// claim). The accessor reads the ambient <see cref="HttpContext.User"/>, so the
    /// fall-through here only fires when no API-key name was stashed;</description></item>
    /// <item><description>otherwise <c>null</c> — never echo an unauthenticated
    /// principal back as an actor.</description></item>
    /// </list>
    /// The accessor is optional (constructor default <c>null</c>); when absent only
    /// the stashed API-key name is consulted, preserving the previous behaviour.
    /// </summary>
    private string? ResolveActor(HttpContext ctx)
    {
        if (ctx.Items.TryGetValue(AuditActorItemKey, out var stashed)
            && stashed is string name
            && !string.IsNullOrWhiteSpace(name))
        {
            return name;
        }

        // An interactive cookie/LDAP-authenticated inbound user records
        // their real identity as Actor. Returns null for the key-authenticated
        // and auth-failure paths (no authenticated interactive principal), so the
        // existing API-key/auth-failure behaviour is preserved.
        return _actorAccessor?.CurrentActor;
    }

    /// <summary>
    /// Pulls the <c>{methodName}</c> route value off the request. Falls back to
    /// the last segment of <see cref="HttpRequest.Path"/> when no route value
    /// is bound (e.g. when the request never reached the matched endpoint).
    /// </summary>
    private static string? ResolveMethodName(HttpContext ctx)
    {
        if (ctx.Request.RouteValues.TryGetValue("methodName", out var raw)
            && raw is string method
            && !string.IsNullOrWhiteSpace(method))
        {
            return method;
        }

        var path = ctx.Request.Path.Value;
        if (string.IsNullOrEmpty(path))
        {
            return null;
        }

        var lastSlash = path.LastIndexOf('/');
        if (lastSlash < 0 || lastSlash == path.Length - 1)
        {
            return null;
        }

        return path[(lastSlash + 1)..];
    }

    /// <summary>
    /// Write-only forwarding <see cref="Stream"/> wrapper that mirrors every
    /// write to the inner ASP.NET <see cref="HttpResponse.Body"/> (so the real
    /// client receives all bytes) while capturing AT MOST <c>cap + 1</c> bytes
    /// into a private bounded <see cref="MemoryStream"/> for the audit copy.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The inner sink is owned by the framework and is NOT disposed when this
    /// wrapper is disposed — we only own the capture <see cref="MemoryStream"/>.
    /// </para>
    /// <para>
    /// All Write overloads forward to the inner stream FIRST, then capture the
    /// remaining quota. If the inner sink throws (e.g. the client disconnects),
    /// the exception is allowed to propagate — capture is best-effort, the
    /// real I/O is authoritative. The handler-throws-mid-response test
    /// (<c>ResponseBody_OnHandlerThrow_BodyCapturedUpToTheThrow</c>) verifies
    /// that captured bytes up to the throw are still recoverable.
    /// </para>
    /// </remarks>
    private sealed class CapturedResponseStream : Stream
    {
        private readonly Stream _inner;
        private readonly int _capBytes;
        private readonly MemoryStream _captured;
        private bool _disposed;

        /// <summary>
        /// Initializes the capturing stream wrapping an inner sink.
        /// </summary>
        /// <param name="inner">The underlying response stream to forward writes to.</param>
        /// <param name="capBytes">Maximum number of bytes to capture for the audit copy.</param>
        public CapturedResponseStream(Stream inner, int capBytes)
        {
            _inner = inner ?? throw new ArgumentNullException(nameof(inner));
            _capBytes = Math.Max(0, capBytes);
            // Capture up to cap + 1 bytes so we can detect the over-cap case
            // without growing the buffer further.
            _captured = new MemoryStream();
        }

        /// <inheritdoc />
        public override bool CanRead => false;
        /// <inheritdoc />
        public override bool CanSeek => false;
        /// <inheritdoc />
        public override bool CanWrite => true;

        /// <inheritdoc />
        public override long Length =>
            throw new NotSupportedException("CapturedResponseStream is write-only.");

        /// <inheritdoc />
        public override long Position
        {
            get => throw new NotSupportedException("CapturedResponseStream is write-only.");
            set => throw new NotSupportedException("CapturedResponseStream is write-only.");
        }

        /// <inheritdoc />
        public override void Flush() => _inner.Flush();

        /// <inheritdoc />
        public override Task FlushAsync(CancellationToken cancellationToken) =>
            _inner.FlushAsync(cancellationToken);

        /// <inheritdoc />
        public override int Read(byte[] buffer, int offset, int count) =>
            throw new NotSupportedException("CapturedResponseStream is write-only.");

        /// <inheritdoc />
        public override long Seek(long offset, SeekOrigin origin) =>
            throw new NotSupportedException("CapturedResponseStream is write-only.");

        /// <inheritdoc />
        public override void SetLength(long value) =>
            throw new NotSupportedException("CapturedResponseStream is write-only.");

        /// <inheritdoc />
        public override void Write(byte[] buffer, int offset, int count)
        {
            // Forward to the real sink FIRST — the client must never miss
            // bytes if capture throws.
            _inner.Write(buffer, offset, count);
            CaptureBytes(buffer.AsSpan(offset, count));
        }

        /// <inheritdoc />
        public override void Write(ReadOnlySpan<byte> buffer)
        {
            _inner.Write(buffer);
            CaptureBytes(buffer);
        }

        /// <inheritdoc />
        public override async Task WriteAsync(
            byte[] buffer, int offset, int count, CancellationToken cancellationToken)
        {
            await _inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken)
                .ConfigureAwait(false);
            CaptureBytes(buffer.AsSpan(offset, count));
        }

        /// <inheritdoc />
        public override async ValueTask WriteAsync(
            ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
        {
            await _inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
            CaptureBytes(buffer.Span);
        }

        /// <summary>
        /// Capture up to <c>cap + 1</c> bytes total into the private
        /// <see cref="MemoryStream"/>. Once the cap quota is reached, further
        /// bytes are silently dropped from the audit copy (the real sink has
        /// already received them upstream of this call).
        /// </summary>
        private void CaptureBytes(ReadOnlySpan<byte> span)
        {
            if (span.Length == 0)
            {
                return;
            }
            var quota = (_capBytes + 1) - (int)_captured.Length;
            if (quota <= 0)
            {
                return;
            }
            var take = Math.Min(quota, span.Length);
            _captured.Write(span[..take]);
        }

        /// <summary>
        /// Returns the captured response body as a UTF-8 string (byte-safe
        /// truncated to <c>cap</c> bytes) and a flag indicating whether the
        /// audit copy hit the cap. Returns <c>(null, false)</c> when no bytes
        /// were captured, mirroring the request-body empty contract.
        /// </summary>
        /// <returns>A tuple of the captured body string (or <c>null</c> if nothing was captured) and a flag indicating whether the cap was exceeded.</returns>
        public (string? body, bool truncated) GetCapturedBody()
        {
            var length = (int)_captured.Length;
            if (length == 0)
            {
                return (null, false);
            }
            var truncated = length > _capBytes;
            var bytes = _captured.GetBuffer();
            var bytesForString = truncated ? _capBytes : length;
            var content = DecodeUtf8Bounded(bytes, bytesForString, cutAtValidBytes: truncated);
            return (string.IsNullOrEmpty(content) ? null : content, truncated);
        }

        /// <inheritdoc />
        protected override void Dispose(bool disposing)
        {
            if (!_disposed)
            {
                if (disposing)
                {
                    // Own only the capture stream; the inner sink belongs to
                    // the framework's response pipeline.
                    _captured.Dispose();
                }
                _disposed = true;
            }
            base.Dispose(disposing);
        }
    }
}
