using System.Globalization; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.CentralUI.Components.Audit; /// /// Child component for the central Audit Log page (#23 M7 Bundle C / M7-T4..T8). /// Renders one in a right-side off-canvas drawer: /// read-only fields, conditional Error/Request/Response/Extra subsections, /// and action buttons (Copy as cURL, Show all events for this operation, /// Close). The drawer is fully presentational — it has no DB or service /// dependencies; the host page owns the open/close state. /// /// /// Body rendering. Request/Response/Extra summaries are strings. /// The drawer pretty-prints JSON when it parses; falls back to verbatim /// otherwise. DbOutbound payloads carry a {sql, parameters} JSON /// shape and get a SQL code block plus a parameter definition list. /// Syntax highlighting is CSS-class-only (language-sql); no JS /// library is loaded — Blazor Server + Bootstrap only per the project's UI /// rules. /// /// /// /// Redaction badges. The audit pipeline replaces redacted values /// with the literal sentinels <redacted> or /// <redacted: redactor error> (see Component-AuditLog.md /// §Redaction). The drawer surfaces a yellow "Redacted" badge on a body /// section when its text contains either sentinel — it does not attempt /// to un-redact or count occurrences. /// /// /// /// Copy as cURL. Best-effort: the URL comes from Target; /// when the RequestSummary parses as {headers, body}, headers are /// folded into -H flags and the body into --data-raw. The /// command is written to the system clipboard via /// . We only /// surface the button for API channels (ApiOutbound / ApiInbound). /// /// /// /// Drill-back. When is set, /// the "Show all events" button navigates to /// /audit/log?correlationId={id}. Likewise, when /// is set the "View this execution" /// button navigates to /audit/log?executionId={id}. Both are deep /// links the Audit Log page deserializes on init (Bundle D) and auto-loads. /// /// public partial class AuditDrilldownDrawer { [Inject] private IJSRuntime JS { get; set; } = null!; [Inject] private NavigationManager Navigation { get; set; } = null!; /// /// The row to render. When null the drawer renders nothing — the host /// page uses this together with to drive visibility. /// [Parameter] public AuditEvent? Event { get; set; } /// /// True when the host wants the drawer visible. We deliberately keep /// this as a separate parameter from : an open /// drawer briefly with a null event renders nothing (the row may still /// be loading); a closed drawer with a stale event is the resting state. /// [Parameter] public bool IsOpen { get; set; } /// /// Fired when the user dismisses the drawer (close button or backdrop /// click). The host is expected to flip to false. /// [Parameter] public EventCallback OnClose { get; set; } private const string RedactionSentinel = ""; private const string RedactorErrorSentinel = ""; private static bool IsApiChannel(AuditChannel channel) => channel is AuditChannel.ApiOutbound or AuditChannel.ApiInbound; private static string ShortEventId(Guid eventId) { // Mirror the "first 8 hex digits" presentation common across the UI. var n = eventId.ToString("N"); return n.Length >= 8 ? n[..8] : n; } private static string FormatTimestamp(DateTime utc) { // Force UTC kind in case the row arrived as Unspecified, then emit // round-trip ISO-8601 so audit drilldowns are copy-paste safe. var kind = utc.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(utc, DateTimeKind.Utc) : utc; return kind.ToString("o", CultureInfo.InvariantCulture); } private static bool IsRedacted(string? text) { if (string.IsNullOrEmpty(text)) return false; return text.Contains(RedactionSentinel, StringComparison.Ordinal) || text.Contains(RedactorErrorSentinel, StringComparison.Ordinal); } /// /// Channel-aware body renderer. DbOutbound bodies that parse as /// {sql, parameters} get a SQL block + parameter list; anything /// else falls back to JSON-pretty-print, then plain-text verbatim. /// private RenderFragment RenderBody(string body, AuditChannel channel) => builder => { // DbOutbound special-case: try to extract {sql, parameters}. if (channel == AuditChannel.DbOutbound && TryParseDbBody(body, out var sql, out var parameters)) { builder.OpenElement(0, "pre"); builder.AddAttribute(1, "class", "bg-light border rounded p-2 mb-2 drawer-pre"); builder.OpenElement(2, "code"); // Highlighting is CSS-class-only — no JS library is loaded. builder.AddAttribute(3, "class", "language-sql"); builder.AddContent(4, sql); builder.CloseElement(); builder.CloseElement(); if (parameters is not null && parameters.Count > 0) { builder.OpenElement(10, "dl"); builder.AddAttribute(11, "class", "row mb-0 small"); builder.AddAttribute(12, "data-test", "sql-parameters"); // The analyzer (ASP0006) requires literal sequence numbers // inside a render fragment. We delegate parameter rendering // to a helper fragment that uses a stable @key per entry, // so per-row diffing stays correct even though the outer // sequence number is fixed. builder.AddContent(13, BuildSqlParameterRows(parameters)); builder.CloseElement(); } return; } // Generic JSON pretty-print path. if (TryPrettyPrintJson(body, out var pretty)) { builder.OpenElement(20, "pre"); builder.AddAttribute(21, "class", "bg-light border rounded p-2 mb-0 drawer-pre json"); builder.AddContent(22, pretty); builder.CloseElement(); return; } // Fallback: verbatim. Wrapping in
 preserves whitespace, which
        // is useful when the body is multi-line plain text or a partial JSON.
        builder.OpenElement(30, "pre");
        builder.AddAttribute(31, "class", "bg-light border rounded p-2 mb-0 drawer-pre");
        builder.AddContent(32, body);
        builder.CloseElement();
    };

    private static RenderFragment BuildSqlParameterRows(List> parameters) => builder =>
    {
        foreach (var kv in parameters)
        {
            // Literal sequence numbers (ASP0006) + per-element SetKey so
            // Blazor's diff is still keyed on parameter name. The "0" base
            // is fine here — each loop iteration produces a disjoint
            // dt/dd pair, and the diff keys on @key, not sequence.
            builder.OpenElement(0, "dt");
            builder.SetKey($"dt-{kv.Key}");
            builder.AddAttribute(1, "class", "col-4 text-muted fw-normal font-monospace");
            builder.AddContent(2, kv.Key);
            builder.CloseElement();

            builder.OpenElement(3, "dd");
            builder.SetKey($"dd-{kv.Key}");
            builder.AddAttribute(4, "class", "col-8 font-monospace");
            builder.AddContent(5, kv.Value);
            builder.CloseElement();
        }
    };

    private static bool TryPrettyPrintJson(string text, out string formatted)
    {
        formatted = text;
        try
        {
            using var doc = JsonDocument.Parse(text);
            formatted = JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
            return true;
        }
        catch (JsonException)
        {
            return false;
        }
    }

    private static string PrettyPrintJson(string text)
        => TryPrettyPrintJson(text, out var pretty) ? pretty : text;

    /// 
    /// Best-effort parse of a DbOutbound {sql, parameters} body.
    /// Returns true only when the JSON has a string sql property;
    /// parameters is treated as an optional object whose values
    /// stringify to scalar text.
    /// 
    private static bool TryParseDbBody(string text, out string sql, out List>? parameters)
    {
        sql = string.Empty;
        parameters = null;
        try
        {
            using var doc = JsonDocument.Parse(text);
            if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
            if (!doc.RootElement.TryGetProperty("sql", out var sqlProp) || sqlProp.ValueKind != JsonValueKind.String)
                return false;
            sql = sqlProp.GetString() ?? string.Empty;

            if (doc.RootElement.TryGetProperty("parameters", out var paramsProp)
                && paramsProp.ValueKind == JsonValueKind.Object)
            {
                parameters = new List>();
                foreach (var p in paramsProp.EnumerateObject())
                {
                    parameters.Add(new KeyValuePair(p.Name, StringifyJsonValue(p.Value)));
                }
            }
            return true;
        }
        catch (JsonException)
        {
            return false;
        }
    }

    private static string StringifyJsonValue(JsonElement value) => value.ValueKind switch
    {
        JsonValueKind.String => value.GetString() ?? string.Empty,
        JsonValueKind.Null => "null",
        JsonValueKind.True => "true",
        JsonValueKind.False => "false",
        JsonValueKind.Number => value.GetRawText(),
        _ => value.GetRawText(),
    };

    private async Task HandleClose()
    {
        if (OnClose.HasDelegate)
        {
            await OnClose.InvokeAsync();
        }
    }

    private async Task CopyCurl()
    {
        if (Event is null) return;

        var curl = BuildCurlCommand(Event);
        try
        {
            await JS.InvokeVoidAsync("navigator.clipboard.writeText", curl);
        }
        catch
        {
            // Clipboard interop can fail (denied permission, prerender, etc.).
            // The drawer stays open; the failure surfaces in the dev console
            // only — we deliberately do not toast here because the parent
            // page owns toast state.
        }
    }

    private void ShowAllForOperation()
    {
        if (Event?.CorrelationId is not { } corr) return;
        var uri = $"/audit/log?correlationId={corr}";
        Navigation.NavigateTo(uri);
    }

    /// 
    /// Drill-in to every audit row sharing this row's 
    /// — the universal per-run correlation value, distinct from the per-operation
    /// CorrelationId drill-back above. Navigates to /audit/log?executionId={id},
    /// which the page parses on init and auto-loads. The button is only rendered
    /// when  is non-null, so this is total.
    /// 
    private void ViewThisExecution()
    {
        if (Event?.ExecutionId is not { } exec) return;
        var uri = $"/audit/log?executionId={exec}";
        Navigation.NavigateTo(uri);
    }

    /// 
    /// Build a cURL command from an audit event. The URL comes from
    /// Target; when the RequestSummary parses as
    /// {headers, body, method?}, headers fold into -H flags
    /// and the body into --data-raw. Default method is POST for
    /// outbound audit rows — the audit pipeline does not always capture
    /// the verb explicitly.
    /// 
    private static string BuildCurlCommand(AuditEvent ev)
    {
        var sb = new StringBuilder();
        sb.Append("curl");

        string method = "POST";
        List>? headers = null;
        string? body = null;

        if (!string.IsNullOrEmpty(ev.RequestSummary))
        {
            TryExtractCurlPartsFromJson(ev.RequestSummary!, ref method, ref headers, ref body);
        }

        sb.Append(' ').Append("-X ").Append(method);

        if (headers is not null)
        {
            foreach (var (name, value) in headers)
            {
                sb.Append(' ').Append("-H ");
                sb.Append(QuoteShellArg($"{name}: {value}"));
            }
        }

        if (!string.IsNullOrEmpty(body))
        {
            sb.Append(' ').Append("--data-raw ");
            sb.Append(QuoteShellArg(body!));
        }

        var url = ev.Target ?? string.Empty;
        sb.Append(' ').Append(QuoteShellArg(url));
        return sb.ToString();
    }

    private static void TryExtractCurlPartsFromJson(
        string requestSummary,
        ref string method,
        ref List>? headers,
        ref string? body)
    {
        try
        {
            using var doc = JsonDocument.Parse(requestSummary);
            if (doc.RootElement.ValueKind != JsonValueKind.Object) return;

            if (doc.RootElement.TryGetProperty("method", out var m) && m.ValueKind == JsonValueKind.String)
            {
                method = m.GetString() ?? method;
            }
            if (doc.RootElement.TryGetProperty("headers", out var hs) && hs.ValueKind == JsonValueKind.Object)
            {
                headers = new List>();
                foreach (var h in hs.EnumerateObject())
                {
                    var value = h.Value.ValueKind == JsonValueKind.String
                        ? h.Value.GetString() ?? string.Empty
                        : h.Value.GetRawText();
                    headers.Add(new KeyValuePair(h.Name, value));
                }
            }
            if (doc.RootElement.TryGetProperty("body", out var b))
            {
                body = b.ValueKind == JsonValueKind.String
                    ? b.GetString()
                    : b.GetRawText();
            }
        }
        catch (JsonException)
        {
            // RequestSummary wasn't the expected {headers, body} shape —
            // just produce a bare cURL with no body/headers.
        }
    }

    /// 
    /// Quote a single shell argument with single quotes, escaping embedded
    /// single quotes via the standard '\'' idiom. This is the same
    /// quoting strategy curl examples use across man pages.
    /// 
    private static string QuoteShellArg(string value)
    {
        if (string.IsNullOrEmpty(value)) return "''";
        var escaped = value.Replace("'", "'\\''", StringComparison.Ordinal);
        return $"'{escaped}'";
    }
}