using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.ManagementService;

/// <summary>
/// Minimal-API endpoints exposing the central Audit Log over HTTP for the
/// ScadaBridge CLI. Three routes:
/// <list type="bullet">
///   <item><c>GET /api/audit/query</c> — keyset-paged JSON page, gated on the
///   <see cref="AuthorizationPolicies.OperationalAudit"/> permission.</item>
///   <item><c>GET /api/audit/export</c> — streamed bulk export (csv / jsonl;
///   parquet returns HTTP 501), gated on the
///   <see cref="AuthorizationPolicies.AuditExport"/> permission.</item>
///   <item><c>GET /api/audit/tree</c> — execution-chain tree rooted at the
///   topmost ancestor of a given <c>executionId</c>, returned as a JSON array
///   of <see cref="ExecutionTreeNode"/>; gated on
///   <see cref="AuthorizationPolicies.OperationalAudit"/>.</item>
/// </list>
///
/// <para>
/// <b>Auth mechanism.</b> ManagementService ships NO ASP.NET authorization-policy
/// pipeline — the existing <c>/management</c> endpoint
/// (<see cref="ManagementEndpoints"/>) authenticates each request by hand:
/// decode HTTP Basic Auth, bind against LDAP via <see cref="LdapAuthService"/>,
/// then map LDAP groups to roles via <see cref="RoleMapper"/>. These audit
/// endpoints follow that exact mechanism rather than <c>.RequireAuthorization()</c>
/// so the CLI authenticates the same way it does for every other management
/// call (HTTP Basic). Permission gating is then a role-set membership check
/// against <see cref="AuthorizationPolicies.OperationalAuditRoles"/> /
/// <see cref="AuthorizationPolicies.AuditExportRoles"/> — the very role sets the
/// CentralUI's <see cref="AuthorizationPolicies"/> enforces, so the two surfaces
/// stay in lockstep.
/// </para>
///
/// <para>
/// <b>Parquet.</b> No Parquet library is referenced by the solution and
/// <c>Component-AuditLog.md</c> explicitly defers Parquet archival to v1.x, so
/// <c>format=parquet</c> returns <c>501 Not Implemented</c> with a guidance
/// message rather than a half-built binary stream. csv + jsonl are fully
/// implemented.
/// </para>
/// </summary>
public static class AuditEndpoints
{
    /// <summary>Default rows per page for <c>/api/audit/query</c>.</summary>
    public const int DefaultPageSize = 100;

    /// <summary>Hard ceiling on a single query page — keeps one page bounded in memory.</summary>
    public const int MaxPageSize = 1000;

    /// <summary>Repository round-trip size used while streaming an export.</summary>
    public const int ExportPageSize = 1000;

    /// <summary>
    /// Serializer for individual <see cref="AuditEvent"/> rows (query results +
    /// jsonl export lines). Drops null columns so a sparse audit row stays
    /// compact on the wire.
    /// </summary>
    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        Converters = { new JsonStringEnumConverter() },
    };

    /// <summary>
    /// Serializer for the <c>/api/audit/query</c> response envelope. Unlike
    /// <see cref="JsonOptions"/> it does NOT ignore nulls — the contract
    /// requires <c>nextCursor</c> to be present as an explicit <c>null</c> when
    /// there is no further page, so the CLI can rely on the key always existing.
    /// </summary>
    private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Converters = { new JsonStringEnumConverter() },
    };

    /// <summary>Default sentinel written by the backfill endpoint when the caller omits <c>sentinel</c>.</summary>
    public const string DefaultBackfillSentinel = "unknown";

    /// <summary>Default batch size for the backfill endpoint when the caller omits <c>batchSize</c>.</summary>
    public const int DefaultBackfillBatchSize = 5000;

    /// <summary>
    /// Registers the <c>/api/audit/query</c>, <c>/api/audit/export</c>,
    /// <c>/api/audit/tree</c>, and <c>POST /api/audit/backfill-source-node</c>
    /// minimal-API endpoints.
    /// </summary>
    /// <param name="endpoints">The endpoint route builder to register routes on.</param>
    /// <returns>The same <paramref name="endpoints"/> builder, for chaining.</returns>
    public static IEndpointRouteBuilder MapAuditAPI(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet("/api/audit/query", (Delegate)HandleQuery);
        endpoints.MapGet("/api/audit/export", (Delegate)HandleExport);
        endpoints.MapGet("/api/audit/tree", (Delegate)HandleTree);
        endpoints.MapPost("/api/audit/backfill-source-node", (Delegate)HandleBackfillSourceNode);
        return endpoints;
    }

    // ─────────────────────────────────────────────────────────────────────
    // GET /api/audit/query
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Handles <c>GET /api/audit/query</c>: authenticates, checks the OperationalAudit permission, and returns a keyset-paged JSON result.
    /// </summary>
    /// <param name="context">The HTTP context for the current request.</param>
    /// <returns>A task that resolves to the HTTP result (200 JSON page, 401, or 403).</returns>
    internal static async Task<IResult> HandleQuery(HttpContext context)
    {
        var auth = await AuthenticateAsync(context);
        if (auth.Failure is not null)
        {
            return auth.Failure;
        }

        if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles))
        {
            return Forbidden("OperationalAudit");
        }

        var filter = ParseFilter(context.Request.Query);
        var restricted = ApplySiteScope(filter, auth.User!);
        if (restricted is null)
        {
            return Forbidden("OperationalAudit");
        }
        filter = restricted;
        var paging = ParsePaging(context.Request.Query);

        var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
        var canonical = await repo.QueryAsync(filter, paging, context.RequestAborted);

        // The cursor for the next page is the last row of this page — but only
        // when the page came back FULL. A short page means there is no next
        // page, so nextCursor is null and the CLI stops paging.
        object? nextCursor = null;
        if (canonical.Count == paging.PageSize && canonical.Count > 0)
        {
            var last = canonical[^1];
            nextCursor = new
            {
                // Canonical OccurredAtUtc is a DateTimeOffset; the cursor key is UTC.
                afterOccurredAtUtc = last.OccurredAtUtc.UtcDateTime,
                afterEventId = last.EventId,
            };
        }

        // Decompose canonical rows into the flat AuditExportRow so the
        // CLI's JSON shape (24-field) is unchanged.
        var events = canonical.Select(AuditExportRow.From).ToList();
        var payload = new { events, nextCursor };
        // EnvelopeJsonOptions keeps an explicit null nextCursor on the wire so
        // the CLI can always read the key. AuditEvent rows render with their
        // full (null-inclusive) shape — a stable schema for the consumer.
        return Results.Text(
            JsonSerializer.Serialize(payload, EnvelopeJsonOptions), "application/json", statusCode: 200);
    }

    // ─────────────────────────────────────────────────────────────────────
    // GET /api/audit/export
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Handles <c>GET /api/audit/export</c>: authenticates, checks the AuditExport permission, and streams the matching rows as CSV or JSONL.
    /// </summary>
    /// <param name="context">The HTTP context for the current request.</param>
    /// <returns>A task that resolves to the HTTP result (streaming CSV/JSONL, 400, 401, 403, or 501).</returns>
    internal static async Task<IResult> HandleExport(HttpContext context)
    {
        var auth = await AuthenticateAsync(context);
        if (auth.Failure is not null)
        {
            return auth.Failure;
        }

        if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
        {
            return Forbidden("AuditExport");
        }

        var format = (context.Request.Query["format"].ToString() ?? string.Empty).Trim().ToLowerInvariant();
        if (string.IsNullOrEmpty(format))
        {
            format = "csv";
        }

        if (format == "parquet")
        {
            // Deferred to v1.x (Component-AuditLog.md §"Deferred"): no Parquet
            // library is in the solution. Return a 501 with actionable guidance
            // rather than a partial binary stream.
            return Results.Json(
                new { error = "Parquet export deferred to v1.x; use csv or jsonl.", code = "NOT_IMPLEMENTED" },
                statusCode: 501);
        }

        if (format != "csv" && format != "jsonl")
        {
            return Results.Json(
                new { error = $"Unsupported export format '{format}'. Use csv, jsonl, or parquet.", code = "BAD_REQUEST" },
                statusCode: 400);
        }

        var filter = ParseFilter(context.Request.Query);
        var restricted = ApplySiteScope(filter, auth.User!);
        if (restricted is null)
        {
            return Forbidden("AuditExport");
        }
        filter = restricted;
        var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();

        var contentType = format == "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson";
        var extension = format == "csv" ? "csv" : "jsonl";
        var fileName = $"audit-log-{DateTime.UtcNow:yyyyMMdd-HHmmss}.{extension}";

        context.Response.ContentType = contentType;
        context.Response.Headers["Content-Disposition"] = $"attachment; filename=\"{fileName}\"";
        // Defeat intermediate proxy buffering so rows stream as each page flushes.
        context.Response.Headers["Cache-Control"] = "no-store";

        if (format == "csv")
        {
            await StreamCsvAsync(repo, filter, context.Response.Body, context.RequestAborted);
        }
        else
        {
            await StreamJsonlAsync(repo, filter, context.Response.Body, context.RequestAborted);
        }

        return Results.Empty;
    }

    // ─────────────────────────────────────────────────────────────────────
    // GET /api/audit/tree
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Handles <c>GET /api/audit/tree?executionId=...</c>: authenticates, checks the
    /// OperationalAudit permission, and returns the full execution-chain tree rooted at
    /// the topmost ancestor of the supplied <c>executionId</c>. The response is a JSON
    /// array of <see cref="ExecutionTreeNode"/> objects (empty array when the id is
    /// not found). Returns HTTP 400 when <c>executionId</c> is absent or not a valid
    /// GUID.
    /// </summary>
    /// <param name="context">The HTTP context for the current request.</param>
    /// <returns>A task that resolves to the HTTP result (200 JSON array, 400, 401, or 403).</returns>
    internal static async Task<IResult> HandleTree(HttpContext context)
    {
        var auth = await AuthenticateAsync(context);
        if (auth.Failure is not null)
        {
            return auth.Failure;
        }

        if (!HasAnyRole(auth.User!, AuthorizationPolicies.OperationalAuditRoles))
        {
            return Forbidden("OperationalAudit");
        }

        var raw = context.Request.Query["executionId"].ToString();
        if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw, out var executionId))
        {
            return Results.Json(
                new { error = "Missing or invalid 'executionId' query parameter (expected a GUID).", code = "BAD_REQUEST" },
                statusCode: 400);
        }

        var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
        var nodes = await repo.GetExecutionTreeAsync(executionId, context.RequestAborted);

        return Results.Json(nodes, JsonOptions);
    }

    // ─────────────────────────────────────────────────────────────────────
    // POST /api/audit/backfill-source-node
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Handles <c>POST /api/audit/backfill-source-node</c>: authenticates (Admin role
    /// required), reads the JSON body for <c>sentinel</c> / <c>before</c> /
    /// <c>batchSize</c>, and calls
    /// <see cref="IAuditLogRepository.BackfillSourceNodeAsync"/> on the maintenance
    /// path.
    ///
    /// <para>
    /// <b>Auth.</b> Admin-only — backfilling the SourceNode column is a one-time ops
    /// procedure that mutates the AuditLog table via the maintenance path (NOT the
    /// append-only writer role). Restricted to <see cref="AuthorizationPolicies.AuditExportRoles"/>
    /// (Administrator) so it is never accessible to Viewer-role users.
    /// </para>
    ///
    /// <para>
    /// <b>Request body.</b>
    /// <code>
    /// {
    ///   "sentinel":  "unknown",   // optional; default "unknown"
    ///   "before":    "2026-01-01T00:00:00Z",  // required ISO-8601 UTC
    ///   "batchSize": 5000         // optional; default 5000
    /// }
    /// </code>
    /// </para>
    ///
    /// <para>
    /// <b>Response (200).</b>
    /// <code>{ "rowsUpdated": 12345, "sentinel": "unknown", "before": "2026-01-01T00:00:00Z" }</code>
    /// </para>
    /// </summary>
    /// <param name="context">The HTTP context for the current request.</param>
    /// <returns>A task that resolves to the HTTP result (200 JSON, 400, 401, or 403).</returns>
    internal static async Task<IResult> HandleBackfillSourceNode(HttpContext context)
    {
        var auth = await AuthenticateAsync(context);
        if (auth.Failure is not null)
        {
            return auth.Failure;
        }

        // Admin-only: backfilling is a one-time ops procedure on the maintenance path.
        if (!HasAnyRole(auth.User!, AuthorizationPolicies.AuditExportRoles))
        {
            return Forbidden("Administrator");
        }

        string bodyText;
        try
        {
            using var reader = new System.IO.StreamReader(context.Request.Body);
            bodyText = await reader.ReadToEndAsync(context.RequestAborted);
        }
        catch (OperationCanceledException)
        {
            return Results.Json(new { error = "Request cancelled.", code = "CANCELLED" }, statusCode: 499);
        }

        string sentinel = DefaultBackfillSentinel;
        DateTime? beforeUtc = null;
        int batchSize = DefaultBackfillBatchSize;

        if (!string.IsNullOrWhiteSpace(bodyText))
        {
            try
            {
                using var doc = System.Text.Json.JsonDocument.Parse(bodyText);
                var root = doc.RootElement;

                if (root.TryGetProperty("sentinel", out var sentinelEl))
                {
                    var s = sentinelEl.GetString();
                    if (!string.IsNullOrWhiteSpace(s))
                    {
                        sentinel = s.Trim();
                    }
                }

                if (root.TryGetProperty("before", out var beforeEl))
                {
                    if (DateTime.TryParse(
                        beforeEl.GetString(),
                        System.Globalization.CultureInfo.InvariantCulture,
                        System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
                        out var parsed))
                    {
                        beforeUtc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
                    }
                    else
                    {
                        return Results.Json(
                            new { error = "Invalid 'before' value; expected ISO-8601 UTC datetime.", code = "BAD_REQUEST" },
                            statusCode: 400);
                    }
                }

                if (root.TryGetProperty("batchSize", out var batchEl) && batchEl.TryGetInt32(out var b) && b > 0)
                {
                    batchSize = b;
                }
            }
            catch (System.Text.Json.JsonException)
            {
                return Results.Json(
                    new { error = "Request body must be valid JSON.", code = "BAD_REQUEST" },
                    statusCode: 400);
            }
        }

        if (beforeUtc is null)
        {
            return Results.Json(
                new { error = "Required field 'before' (ISO-8601 UTC datetime) is missing.", code = "BAD_REQUEST" },
                statusCode: 400);
        }

        var repo = context.RequestServices.GetRequiredService<IAuditLogRepository>();
        var rowsUpdated = await repo.BackfillSourceNodeAsync(sentinel, beforeUtc.Value, batchSize, context.RequestAborted);

        return Results.Json(new
        {
            rowsUpdated,
            sentinel,
            before = beforeUtc.Value.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
        }, JsonOptions);
    }

    /// <summary>
    /// Streams every matching row as RFC 4180 CSV, paging the repository with its
    /// keyset cursor and flushing after each page so a large export starts
    /// arriving immediately. Column order matches <see cref="AuditEvent"/>.
    /// </summary>
    private static async Task StreamCsvAsync(
        IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
    {
        await using var writer = new StreamWriter(
            output,
            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
            bufferSize: 4096,
            leaveOpen: true)
        {
            NewLine = "\r\n",
        };

        await writer.WriteLineAsync(CsvHeader);

        await foreach (var page in PageAsync(repo, filter, ct))
        {
            foreach (var evt in page)
            {
                await writer.WriteLineAsync(FormatCsvRow(AuditExportRow.From(evt)));
            }
            await writer.FlushAsync(ct);
            await output.FlushAsync(ct);
        }
    }

    /// <summary>
    /// Streams every matching row as newline-delimited JSON (one compact object
    /// per line), flushing after each page.
    /// </summary>
    private static async Task StreamJsonlAsync(
        IAuditLogRepository repo, AuditLogQueryFilter filter, Stream output, CancellationToken ct)
    {
        await using var writer = new StreamWriter(
            output,
            new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
            bufferSize: 4096,
            leaveOpen: true)
        {
            NewLine = "\n",
        };

        await foreach (var page in PageAsync(repo, filter, ct))
        {
            foreach (var evt in page)
            {
                await writer.WriteLineAsync(JsonSerializer.Serialize(AuditExportRow.From(evt), JsonOptions));
            }
            await writer.FlushAsync(ct);
            await output.FlushAsync(ct);
        }
    }

    /// <summary>
    /// Lazily yields full repository pages, advancing the keyset cursor until a
    /// short page signals the end. Shared by the csv + jsonl streamers.
    /// </summary>
    private static async IAsyncEnumerable<IReadOnlyList<AuditEvent>> PageAsync(
        IAuditLogRepository repo,
        AuditLogQueryFilter filter,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
    {
        var cursor = new AuditLogPaging(ExportPageSize);
        while (true)
        {
            ct.ThrowIfCancellationRequested();
            var page = await repo.QueryAsync(filter, cursor, ct);
            if (page.Count == 0)
            {
                yield break;
            }

            yield return page;

            if (page.Count < cursor.PageSize)
            {
                yield break;
            }

            var last = page[^1];
            // Canonical OccurredAtUtc is a DateTimeOffset; the keyset cursor column is UTC.
            cursor = new AuditLogPaging(ExportPageSize, last.OccurredAtUtc.UtcDateTime, last.EventId);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // Authentication — Basic Auth → LDAP → roles (mirrors ManagementEndpoints)
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Outcome of <see cref="AuthenticateAsync"/>: exactly one of the two fields is set.</summary>
    private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);

    /// <summary>
    /// Authenticates the audit-REST request via the shared
    /// <see cref="ManagementAuthenticator.AuthenticateAsync"/> — the dev/test
    /// <c>DisableLogin</c> bypass first, otherwise HTTP Basic → LDAP → roles, the same flow
    /// <see cref="ManagementEndpoints"/> uses. Returns a populated <see cref="AuthenticatedUser"/>
    /// on success, or an <see cref="IResult"/> carrying the 401 response on any failure.
    /// </summary>
    private static async Task<AuthOutcome> AuthenticateAsync(HttpContext context)
    {
        var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
        return new AuthOutcome(outcome.User, outcome.Failure);
    }

    private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) =>
        user.Roles.Any(r => allowed.Contains(r, StringComparer.OrdinalIgnoreCase));

    private static IResult Forbidden(string permission) => Results.Json(
        new { error = $"Permission '{permission}' required.", code = "UNAUTHORIZED" }, statusCode: 403);

    /// <summary>
    /// Applies the caller's <see cref="AuthenticatedUser.PermittedSiteIds"/> to the
    /// audit-log filter. System-wide callers (empty PermittedSiteIds —
    /// Admin or a Deployment-style role with no scope rules attached to its mapping)
    /// see the filter unchanged. A scoped caller has any caller-supplied
    /// <c>sourceSiteId</c> intersected with their permitted set: an empty caller filter
    /// is replaced by the permitted set; an explicit out-of-scope filter (no overlap
    /// with the permitted set) returns <c>null</c> so the caller gets a 403 rather
    /// than silently empty results.
    /// </summary>
    /// <param name="filter">The caller-supplied audit-log query filter to scope.</param>
    /// <param name="user">The authenticated user whose permitted site IDs constrain the filter.</param>
    /// <returns>
    /// The restricted filter, or <c>null</c> if the caller explicitly asked for
    /// sites entirely outside their permitted set.
    /// </returns>
    public static AuditLogQueryFilter? ApplySiteScope(AuditLogQueryFilter filter, AuthenticatedUser user)
    {
        // Empty PermittedSiteIds is the system-wide signal (Administrator,
        // system-wide Deployer). System-wide audit roles also fall here — the
        // design treats Administrator/Viewer (the audit roles) as
        // non-site-scoped unless an operator attaches scope rules to the LDAP
        // mapping; if they do, this helper enforces them.
        if (user.PermittedSiteIds.Length == 0)
        {
            return filter;
        }

        var permitted = new HashSet<string>(user.PermittedSiteIds, StringComparer.Ordinal);

        if (filter.SourceSiteIds is null || filter.SourceSiteIds.Count == 0)
        {
            // No explicit filter — restrict to permitted set.
            return filter with { SourceSiteIds = permitted.ToArray() };
        }

        // Explicit filter — intersect.
        var intersection = filter.SourceSiteIds.Where(permitted.Contains).ToArray();
        if (intersection.Length == 0)
        {
            return null;
        }

        return filter with { SourceSiteIds = intersection };
    }

    // ─────────────────────────────────────────────────────────────────────
    // Query-string parsing
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Parses the query-string into an <see cref="AuditLogQueryFilter"/>. The
    /// <c>channel</c>/<c>kind</c>/<c>status</c>/<c>sourceSiteId</c> dimensions are
    /// multi-value: a repeated query param (<c>channel=A&amp;channel=B</c>) yields
    /// a multi-element filter list, while a single param yields a one-element
    /// list. Unknown enum names / un-parseable Guids / dates are silently dropped
    /// (no 400) — the same lax contract the CentralUI export endpoint uses; an
    /// unparseable value within a repeated set is dropped, not the whole set.
    /// </summary>
    /// <remarks>
    /// This endpoint reads the source-site filter from the <c>sourceSiteId</c>
    /// query key, whereas the CentralUI export endpoint reads it as <c>site</c>.
    /// The divergence is deliberate — each endpoint matches its own CLI / UI URL
    /// builder — so do NOT "fix" the two to a single key name.
    /// </remarks>
    /// <param name="query">The HTTP query string collection to parse filter parameters from.</param>
    /// <returns>An <see cref="AuditLogQueryFilter"/> built from the supplied query parameters.</returns>
    public static AuditLogQueryFilter ParseFilter(IQueryCollection query)
    {
        var channels = AuditQueryParamParsers.ParseEnumList<AuditChannel>(query["channel"]);
        var kinds = AuditQueryParamParsers.ParseEnumList<AuditKind>(query["kind"]);
        var statuses = AuditQueryParamParsers.ParseEnumList<AuditStatus>(query["status"]);
        var sourceSiteIds = AuditQueryParamParsers.ParseStringList(query["sourceSiteId"]);

        Guid? correlationId = null;
        if (query.TryGetValue("correlationId", out var corrValues)
            && Guid.TryParse(corrValues.ToString(), out var parsedCorr))
        {
            correlationId = parsedCorr;
        }

        Guid? executionId = null;
        if (query.TryGetValue("executionId", out var execValues)
            && Guid.TryParse(execValues.ToString(), out var parsedExec))
        {
            executionId = parsedExec;
        }

        Guid? parentExecutionId = null;
        if (query.TryGetValue("parentExecutionId", out var parentExecValues)
            && Guid.TryParse(parentExecValues.ToString(), out var parsedParentExec))
        {
            parentExecutionId = parsedParentExec;
        }

        return new AuditLogQueryFilter(
            Channels: channels,
            Kinds: kinds,
            Statuses: statuses,
            SourceSiteIds: sourceSiteIds,
            Target: TrimToNullable(query, "target"),
            Actor: TrimToNullable(query, "actor"),
            CorrelationId: correlationId,
            ExecutionId: executionId,
            ParentExecutionId: parentExecutionId,
            FromUtc: ParseUtcDate(query, "fromUtc"),
            ToUtc: ParseUtcDate(query, "toUtc"));
    }

    /// <summary>
    /// Parses the keyset-paging query parameters into an
    /// <see cref="AuditLogPaging"/>. <c>pageSize</c> is clamped to
    /// <c>[1, <see cref="MaxPageSize"/>]</c>; a missing / junk value falls back
    /// to <see cref="DefaultPageSize"/>. <c>afterOccurredAtUtc</c> +
    /// <c>afterEventId</c> are honoured only when BOTH parse (the repository's
    /// keyset cursor requires the pair together).
    /// </summary>
    /// <param name="query">The HTTP query string collection to parse paging parameters from.</param>
    /// <returns>An <see cref="AuditLogPaging"/> built from the supplied query parameters.</returns>
    public static AuditLogPaging ParsePaging(IQueryCollection query)
    {
        int pageSize = DefaultPageSize;
        if (query.TryGetValue("pageSize", out var pageSizeRaw)
            && int.TryParse(pageSizeRaw.ToString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize)
            && parsedSize > 0)
        {
            pageSize = Math.Min(parsedSize, MaxPageSize);
        }

        DateTime? afterOccurredAt = ParseUtcDate(query, "afterOccurredAtUtc");

        Guid? afterEventId = null;
        if (query.TryGetValue("afterEventId", out var eventIdRaw)
            && Guid.TryParse(eventIdRaw.ToString(), out var parsedEventId))
        {
            afterEventId = parsedEventId;
        }

        // The cursor pair must be all-or-nothing — a half-supplied cursor would
        // produce an invalid keyset predicate. Drop both unless both are present.
        if (afterOccurredAt is null || afterEventId is null)
        {
            return new AuditLogPaging(pageSize);
        }

        return new AuditLogPaging(pageSize, afterOccurredAt, afterEventId);
    }

    private static string? TrimToNullable(IQueryCollection query, string key)
    {
        if (!query.TryGetValue(key, out var values))
        {
            return null;
        }
        var v = values.ToString();
        return string.IsNullOrWhiteSpace(v) ? null : v.Trim();
    }

    private static DateTime? ParseUtcDate(IQueryCollection query, string key)
    {
        if (!query.TryGetValue(key, out var values))
        {
            return null;
        }
        if (DateTime.TryParse(
            values.ToString(),
            CultureInfo.InvariantCulture,
            DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
            out var parsed))
        {
            return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
        }
        return null;
    }

    // ─────────────────────────────────────────────────────────────────────
    // CSV helpers — 21 columns in AuditEvent declaration order
    // ─────────────────────────────────────────────────────────────────────

    public const string CsvHeader =
        "EventId,OccurredAtUtc,IngestedAtUtc,Channel,Kind,CorrelationId," +
        "SourceSiteId,SourceInstanceId,SourceScript,Actor,Target,Status," +
        "HttpStatus,DurationMs,ErrorMessage,ErrorDetail,RequestSummary," +
        "ResponseSummary,PayloadTruncated,Extra,ForwardState";

    /// <summary>
    /// Formats a single <see cref="AuditEvent"/> as an RFC 4180 CSV row matching <see cref="CsvHeader"/>.
    /// </summary>
    /// <param name="evt">The audit event to format.</param>
    /// <returns>An RFC 4180 CSV row string (no trailing newline) for the supplied event.</returns>
    public static string FormatCsvRow(AuditExportRow evt)
    {
        var sb = new StringBuilder(256);
        AppendField(sb, evt.EventId.ToString(), first: true);
        AppendField(sb, FormatDate(evt.OccurredAtUtc));
        AppendField(sb, FormatDate(evt.IngestedAtUtc));
        AppendField(sb, evt.Channel.ToString());
        AppendField(sb, evt.Kind.ToString());
        AppendField(sb, evt.CorrelationId?.ToString());
        AppendField(sb, evt.SourceSiteId);
        AppendField(sb, evt.SourceInstanceId);
        AppendField(sb, evt.SourceScript);
        AppendField(sb, evt.Actor);
        AppendField(sb, evt.Target);
        AppendField(sb, evt.Status.ToString());
        AppendField(sb, evt.HttpStatus?.ToString(CultureInfo.InvariantCulture));
        AppendField(sb, evt.DurationMs?.ToString(CultureInfo.InvariantCulture));
        AppendField(sb, evt.ErrorMessage);
        AppendField(sb, evt.ErrorDetail);
        AppendField(sb, evt.RequestSummary);
        AppendField(sb, evt.ResponseSummary);
        AppendField(sb, evt.PayloadTruncated.ToString());
        AppendField(sb, evt.Extra);
        AppendField(sb, evt.ForwardState?.ToString());
        return sb.ToString();
    }

    private static string? FormatDate(DateTime? value) =>
        value?.ToString("O", CultureInfo.InvariantCulture);

    private static string FormatDate(DateTime value) =>
        value.ToString("O", CultureInfo.InvariantCulture);

    private static void AppendField(StringBuilder sb, string? value, bool first = false)
    {
        if (!first) sb.Append(',');
        if (string.IsNullOrEmpty(value))
        {
            return;
        }

        // RFC 4180: quote on comma / quote / CR / LF; double-up embedded quotes.
        if (value.IndexOfAny(s_quoteTriggers) < 0)
        {
            sb.Append(value);
            return;
        }

        sb.Append('"');
        foreach (var ch in value)
        {
            if (ch == '"')
            {
                sb.Append('"').Append('"');
            }
            else
            {
                sb.Append(ch);
            }
        }
        sb.Append('"');
    }

    private static readonly char[] s_quoteTriggers = { ',', '"', '\r', '\n' };
}
