using System.Text.Json;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;

/// <summary>
/// Keyset-paged results grid for the central Audit Log page.
/// Renders the columns named in Component-AuditLog.md §10 — OccurredAtUtc,
/// Site, Channel, Kind, Status, Target, Actor, DurationMs, HttpStatus,
/// ErrorMessage — plus the ExecutionId per-run correlation column and the
/// ParentExecutionId spawner-correlation column. Talks to
/// <see cref="Services.IAuditLogQueryService"/>
/// — never to <c>IAuditLogRepository</c> directly — so tests can stub the data
/// source without standing up EF Core.
///
/// <para>
/// <b>Column model.</b> Each column has a stable string key. The default
/// visible order is the <see cref="ColumnOrder"/> parameter (or the spec
/// order from Component-AuditLog.md §10 when the parameter is null). On top of
/// that default the grid layers a per-browser override: drag-to-reorder and
/// drag-to-resize UX (audit-grid.js) writes the chosen order + per-column
/// widths to <c>sessionStorage</c>, and the grid restores them on first
/// render. A stored order that names an unknown/removed column degrades
/// gracefully — unknown keys are dropped, missing columns appended in default
/// order — so it never throws.
/// </para>
///
/// <para>
/// <b>Pagination.</b> Each page is a single call to
/// <c>IAuditLogQueryService.QueryAsync</c>. The "Next page" button uses the
/// LAST row of the current page as the keyset cursor — repository orders by
/// <c>(OccurredAtUtc DESC, EventId DESC)</c>, so the oldest row in the visible
/// page becomes <c>AfterOccurredAtUtc</c> + <c>AfterEventId</c> on the next
/// request. The button is disabled when the current page is short (less than
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query.
/// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary>
public partial class AuditResultsGrid : IAsyncDisposable
{
    private const int DefaultPageSize = 100;

    /// <summary>Minimum persisted column width — mirrors <c>auditGrid.minWidth</c>.</summary>
    private const int MinColumnWidthPx = 64;

    /// <summary>sessionStorage keys (namespaced under <c>auditGrid:</c> by the JS helper).</summary>
    private const string ColumnOrderStorageKey = "columnOrder";
    private const string ColumnWidthsStorageKey = "columnWidths";

    private readonly List<AuditEventView> _rows = new();
    private int _pageNumber = 1;
    private bool _loading;
    private string? _error;

    // Small in-component stack of prior-page cursors so the user
    // can step backwards through results. Each Next push captures the cursor
    // that produced the current page (null for page 1) before advancing; each
    // Previous pop reloads the page at the recovered cursor. Mirrors the
    // SiteCallsReport keyset-paging shape called out in the finding.
    private readonly Stack<AuditLogPaging?> _cursorStack = new();
    // The cursor that produced the page currently on screen — kept so Next can
    // push it before advancing without recomputing it from _rows.
    private AuditLogPaging? _currentPaging;

    private AuditLogQueryFilter? _activeFilter;

    [Inject] private IJSRuntime JS { get; set; } = default!;

    private ElementReference _tableRef;
    private DotNetObjectReference<AuditResultsGrid>? _selfRef;

    // Effective column state. _columnOrder is the live display order (seeded
    // from the ColumnOrder parameter / spec default, then overridden by any
    // persisted sessionStorage order). _columnWidths holds per-key pixel
    // widths from a prior resize; absent keys render at auto width.
    private List<string>? _columnOrder;
    private readonly Dictionary<string, int> _columnWidths = new();

    /// <summary>
    /// Filter to apply. When this parameter changes the grid resets to page 1 and
    /// reissues the query — that's the contract the parent page relies on so the
    /// filter-bar Apply button does not need to drive grid state manually.
    /// </summary>
    [Parameter] public AuditLogQueryFilter? Filter { get; set; }

    /// <summary>Page size. Defaults to 100 to match the service-level default.</summary>
    [Parameter] public int PageSize { get; set; } = DefaultPageSize;

    /// <summary>
    /// Optional column order — list of column keys in display order. When null or
    /// empty the default order from Component-AuditLog.md §10 is used. The grid
    /// silently drops unknown keys.
    /// </summary>
    [Parameter] public IReadOnlyList<string>? ColumnOrder { get; set; }

    /// <summary>
    /// Raised when the user clicks a row. Consumers wire this to the drilldown
    /// drawer. The event payload is the full <see cref="AuditEventView"/>.
    /// </summary>
    [Parameter] public EventCallback<AuditEventView> OnRowSelected { get; set; }

    // Effective page size used when paging. Mirrors PageSize but bounded > 0.
    private int _pageSize => Math.Max(1, PageSize);

    /// <summary>
    /// Default column definitions. The key is the stable identifier (used by
    /// <c>data-test</c> + the column-order parameter); the label is the user-facing
    /// header text. Mirrors Component-AuditLog.md §10.
    /// </summary>
    // Label intentionally equals Key for every column today; the separate Label
    // field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
    // populating it is a deliberate later change, out of scope here.
    private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
    {
        ("OccurredAtUtc", "OccurredAtUtc"),
        ("Site", "Site"),
        ("Node", "Node"),
        ("Channel", "Channel"),
        ("Kind", "Kind"),
        ("Status", "Status"),
        ("Target", "Target"),
        ("Actor", "Actor"),
        ("ExecutionId", "ExecutionId"),
        ("ParentExecutionId", "ParentExecutionId"),
        ("DurationMs", "DurationMs"),
        ("HttpStatus", "HttpStatus"),
        ("ErrorMessage", "ErrorMessage"),
    };

    private IReadOnlyList<(string Key, string Label)> OrderedColumns()
        => ResolveOrder(_columnOrder ?? ColumnOrder);

    /// <summary>
    /// Resolves a candidate list of column keys into the concrete display
    /// columns. Degrades gracefully so a stale persisted order is never fatal:
    /// unknown keys are dropped, and any column not named in the candidate
    /// list is appended in its default (spec) position. A null/empty candidate
    /// yields the full default order.
    /// </summary>
    private static IReadOnlyList<(string Key, string Label)> ResolveOrder(IReadOnlyList<string>? candidate)
    {
        if (candidate is null || candidate.Count == 0)
        {
            return AllColumns;
        }

        var byKey = AllColumns.ToDictionary(c => c.Key, c => c);
        var ordered = new List<(string Key, string Label)>(AllColumns.Count);
        var seen = new HashSet<string>();
        foreach (var key in candidate)
        {
            // Drop unknown keys (removed/renamed columns) and any duplicates.
            if (byKey.TryGetValue(key, out var col) && seen.Add(key))
            {
                ordered.Add(col);
            }
        }

        // Append any columns the candidate omitted, in default order, so a
        // newly-added column still appears after a restore of an older order.
        foreach (var col in AllColumns)
        {
            if (seen.Add(col.Key))
            {
                ordered.Add(col);
            }
        }

        return ordered;
    }

    /// <summary>
    /// Inline style for a column's cells: emits the <c>--audit-col-width</c>
    /// custom property the scoped stylesheet reads, or an empty string when
    /// the column has no persisted width (auto layout).
    /// </summary>
    private string ColumnWidthStyle(string key)
        => _columnWidths.TryGetValue(key, out var width)
            ? $"--audit-col-width: {width}px;"
            : string.Empty;

    /// <inheritdoc />
    protected override async Task OnParametersSetAsync()
    {
        // Reset & reload whenever the filter reference changes. AuditLogQueryFilter
        // is a record, so equality-by-value gives us a free "did the user click Apply
        // with the same chips?" no-op signal. We pin to ReferenceEquals here so the
        // grid reloads only when the parent hands us a new filter instance — the
        // page wraps Apply in a fresh allocation, which is the canonical reload signal.
        if (!ReferenceEquals(_activeFilter, Filter))
        {
            _activeFilter = Filter;
            _pageNumber = 1;
            _rows.Clear();
            _cursorStack.Clear();
            _currentPaging = null;
            if (Filter is not null)
            {
                await LoadAsync(paging: null);
            }
        }
    }

    private async Task NextPage()
    {
        if (_rows.Count == 0 || _activeFilter is null)
        {
            return;
        }

        var last = _rows[^1];
        var cursor = new AuditLogPaging(
            PageSize: _pageSize,
            AfterOccurredAtUtc: last.OccurredAtUtc,
            AfterEventId: last.EventId);

        // Remember the cursor that produced the current page so
        // a later Previous can navigate back to it. The first-page entry is pushed
        // as null — LoadAsync treats null as "first page" (PageSize-only).
        _cursorStack.Push(_currentPaging);
        await LoadAsync(cursor);
        _pageNumber++;
    }

    // Pops the previous-page cursor off the stack and reloads
    // at that position. The pop only happens AFTER a successful reload — a
    // failed page-fetch leaves the user on the current page with the error
    // banner instead of stranding them between pages.
    private async Task PrevPage()
    {
        if (_cursorStack.Count == 0 || _activeFilter is null)
        {
            return;
        }

        var prior = _cursorStack.Peek();
        await LoadAsync(prior);
        if (_error is null)
        {
            _cursorStack.Pop();
            _pageNumber = Math.Max(1, _pageNumber - 1);
        }
    }

    private bool CanGoBack => _cursorStack.Count > 0;

    private async Task LoadAsync(AuditLogPaging? paging)
    {
        if (_activeFilter is null)
        {
            return;
        }

        _loading = true;
        _error = null;
        try
        {
            var effective = paging ?? new AuditLogPaging(_pageSize);
            var page = await QueryService.QueryAsync(_activeFilter, effective);
            _rows.Clear();
            _rows.AddRange(page);
            // Track the cursor that produced the page now on screen so a later
            // Next can push it onto the stack before advancing.
            _currentPaging = paging;
        }
        catch (Exception ex)
        {
            // Surface the error in-place; the grid stays alive so the user can
            // adjust the filter and retry without a page refresh.
            _error = $"Query failed: {ex.Message}";
        }
        finally
        {
            _loading = false;
        }
    }

    private async Task HandleRowClick(AuditEventView row)
    {
        if (OnRowSelected.HasDelegate)
        {
            await OnRowSelected.InvokeAsync(row);
        }
    }

    /// <inheritdoc />
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // Restore any persisted order + widths first; the StateHasChanged
            // inside triggers a re-render so the restored layout is on screen.
            await LoadPersistedStateAsync();
            _selfRef = DotNetObjectReference.Create(this);
        }

        // Wire (or re-wire) the JS drag handlers on every render. auditGrid.init
        // is idempotent — already-bound cells are skipped, and the .NET
        // reference is refreshed — so a re-render after a reorder still leaves
        // every header cell wired without leaking handlers.
        //
        // OnColumnResized/OnColumnReordered both call StateHasChanged(), which
        // re-runs this method and calls init again. That repeat call is an
        // intentional cheap no-op: the @key-stable <th> nodes plus the
        // __auditGridCellBound guard mean init re-scans the header and rebinds
        // nothing — so there is deliberately no gating logic here.
        if (_selfRef is not null)
        {
            try
            {
                await JS.InvokeVoidAsync("auditGrid.init", _tableRef, _selfRef);
            }
            catch (JSDisconnectedException)
            {
                // Circuit gone before init completed — nothing to wire.
            }
        }
    }

    /// <summary>
    /// Reads the persisted column order + widths from <c>sessionStorage</c> and
    /// applies them. A missing, empty, or corrupt payload is treated as "no
    /// prior state" — the grid keeps its default order/widths and never throws.
    /// </summary>
    private async Task LoadPersistedStateAsync()
    {
        var orderJson = await TryLoadAsync(ColumnOrderStorageKey);
        var widthsJson = await TryLoadAsync(ColumnWidthsStorageKey);

        var changed = false;

        if (!string.IsNullOrEmpty(orderJson))
        {
            try
            {
                var stored = JsonSerializer.Deserialize<List<string>>(orderJson);
                if (stored is { Count: > 0 })
                {
                    // Normalise through ResolveOrder so a stale key never sticks.
                    _columnOrder = ResolveOrder(stored).Select(c => c.Key).ToList();
                    changed = true;
                }
            }
            catch (JsonException)
            {
                // Corrupt payload — ignore, keep the default order.
            }
        }

        if (!string.IsNullOrEmpty(widthsJson))
        {
            try
            {
                var stored = JsonSerializer.Deserialize<Dictionary<string, int>>(widthsJson);
                if (stored is not null)
                {
                    var validKeys = AllColumns.Select(c => c.Key).ToHashSet();
                    _columnWidths.Clear();
                    foreach (var (key, width) in stored)
                    {
                        // Drop widths for unknown columns; clamp to the minimum.
                        if (validKeys.Contains(key))
                        {
                            _columnWidths[key] = Math.Max(MinColumnWidthPx, width);
                        }
                    }
                    changed = _columnWidths.Count > 0 || changed;
                }
            }
            catch (JsonException)
            {
                // Corrupt payload — ignore, keep auto widths.
            }
        }

        if (changed)
        {
            StateHasChanged();
        }
    }

    private async Task<string?> TryLoadAsync(string key)
    {
        try
        {
            return await JS.InvokeAsync<string?>("auditGrid.load", key);
        }
        catch (JSDisconnectedException)
        {
            return null;
        }
    }

    /// <summary>
    /// JS callback: the user finished resizing a column. Persists the new
    /// per-column width and re-renders so the body cells track the header.
    /// </summary>
    /// <param name="columnKey">The stable key of the resized column.</param>
    /// <param name="widthPx">The new column width in pixels.</param>
    /// <returns>A task that completes when the column width has been persisted and the component re-rendered.</returns>
    [JSInvokable]
    public async Task OnColumnResized(string columnKey, int widthPx)
    {
        if (!AllColumns.Any(c => c.Key == columnKey))
        {
            return;
        }

        _columnWidths[columnKey] = Math.Max(MinColumnWidthPx, widthPx);
        await SaveAsync(ColumnWidthsStorageKey, JsonSerializer.Serialize(_columnWidths));
        StateHasChanged();
    }

    /// <summary>
    /// JS callback: the user dropped column <paramref name="fromKey"/> onto the
    /// header of <paramref name="toKey"/>. Moves the dragged column into the
    /// target's slot, persists the resulting order, and re-renders.
    /// </summary>
    /// <param name="fromKey">The stable key of the column being dragged.</param>
    /// <param name="toKey">The stable key of the target column drop slot.</param>
    /// <returns>A task that completes when the column order has been persisted and the component re-rendered.</returns>
    [JSInvokable]
    public async Task OnColumnReordered(string fromKey, string toKey)
    {
        // Start from the current effective order so successive drags compose.
        var order = OrderedColumns().Select(c => c.Key).ToList();
        var fromIndex = order.IndexOf(fromKey);
        var toIndex = order.IndexOf(toKey);
        if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
        {
            return;
        }

        order.RemoveAt(fromIndex);
        // After the removal the target index shifts left by one when the
        // dragged column originally sat before it.
        if (fromIndex < toIndex)
        {
            toIndex--;
        }
        order.Insert(toIndex, fromKey);

        _columnOrder = order;
        await SaveAsync(ColumnOrderStorageKey, JsonSerializer.Serialize(order));
        StateHasChanged();
    }

    private async Task SaveAsync(string key, string json)
    {
        try
        {
            await JS.InvokeVoidAsync("auditGrid.save", key, json);
        }
        catch (JSDisconnectedException)
        {
            // Circuit gone — the in-memory state still drives this render.
        }
    }

    /// <summary>
    /// Releases the .NET object reference held for JS interop callbacks.
    /// </summary>
    /// <returns>A completed value task.</returns>
    public ValueTask DisposeAsync()
    {
        _selfRef?.Dispose();
        return ValueTask.CompletedTask;
    }

    private static string StatusBadgeClass(AuditStatus status) => status switch
    {
        AuditStatus.Delivered => "badge bg-success",
        AuditStatus.Failed or AuditStatus.Parked or AuditStatus.Discarded => "badge bg-danger",
        _ => "badge bg-secondary",
    };

    /// <summary>
    /// Caps the rendered Target text, with the full value kept on the cell's
    /// <c>title</c>.
    /// </summary>
    /// <remarks>
    /// Target is free text this app does not control (external-system URLs, SQL
    /// statements, method names) and its column is UNBOUNDED until the operator
    /// resizes it: the scoped stylesheet clips <c>.audit-grid-td</c> only when the
    /// inline <c>--audit-col-width</c> custom property is present, and
    /// <see cref="ColumnWidthStyle"/> emits nothing for a column with no persisted
    /// width — so on a fresh session one long target sets the table's width.
    /// The cap is applied to the STRING rather than by pinning a CSS width, because
    /// a <c>max-width</c>/<c>nowrap</c> clip would raise the cell's min-content
    /// width and stop a resized column from shrinking — i.e. it would break
    /// drag-to-resize at exactly the widths it is used at. Truncating characters
    /// leaves the cell's min-content word-based, so resize keeps working unchanged.
    /// Mirrors <see cref="TruncateError"/>, this grid's existing idiom.
    /// </remarks>
    private static string TruncateTarget(string? target)
    {
        if (string.IsNullOrEmpty(target))
        {
            return "—";
        }
        const int max = 60;
        return target.Length <= max ? target : string.Concat(target.AsSpan(0, max), "…");
    }

    private static string TruncateError(string? message)
    {
        if (string.IsNullOrEmpty(message))
        {
            return "—";
        }
        const int max = 80;
        return message.Length <= max ? message : string.Concat(message.AsSpan(0, max), "…");
    }
}
