using Bunit;
using Microsoft.AspNetCore.Components;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Audit;
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.Tests.Components.Audit;

/// <summary>
/// bUnit tests for <see cref="AuditResultsGrid"/> (#23 M7-T3 / Bundle B). The grid
/// renders 10 columns, paginates via keyset (passing the last row's
/// (OccurredAtUtc, EventId) back to the service), raises a row-click callback
/// that Bundle C wires to the drilldown drawer, and styles non-success status
/// rows with an error-coded badge.
/// </summary>
public class AuditResultsGridTests : BunitContext
{
    private readonly IAuditLogQueryService _service;
    private readonly List<(AuditLogQueryFilter Filter, AuditLogPaging? Paging)> _calls = new();

    private static AuditEventView MakeEvent(DateTime occurredAtUtc, AuditStatus status, AuditChannel channel = AuditChannel.ApiOutbound, AuditKind kind = AuditKind.ApiCall, string? site = "plant-a", Guid? executionId = null, Guid? parentExecutionId = null)
        => new()
        {
            EventId = Guid.NewGuid(),
            OccurredAtUtc = occurredAtUtc,
            Channel = channel,
            Kind = kind,
            Status = status,
            SourceSiteId = site,
            Target = "demo-target",
            Actor = "tester",
            ExecutionId = executionId,
            ParentExecutionId = parentExecutionId,
            DurationMs = 42,
            HttpStatus = status == AuditStatus.Delivered ? 200 : 500,
            ErrorMessage = status == AuditStatus.Failed ? "boom — unreachable" : null,
        };

    public AuditResultsGridTests()
    {
        _service = Substitute.For<IAuditLogQueryService>();
        _service.DefaultPageSize.Returns(100);
        Services.AddSingleton(_service);

        // The grid's OnAfterRenderAsync calls into audit-grid.js (init + the
        // sessionStorage load). Loose mode lets those unconfigured calls no-op
        // — auditGrid.load returns null (no prior state) unless a test sets up
        // an explicit JSInterop.Setup to return a stored payload.
        JSInterop.Mode = JSRuntimeMode.Loose;
    }

    private void StubPage(IReadOnlyList<AuditEventView> rows)
    {
        _service.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging?>(), Arg.Any<CancellationToken>())
            .Returns(callInfo =>
            {
                _calls.Add(((AuditLogQueryFilter)callInfo[0], (AuditLogPaging?)callInfo[1]));
                return Task.FromResult(rows);
            });
    }

    [Fact]
    public void Render_TenColumns_FromStubService()
    {
        StubPage(new List<AuditEventView>
        {
            MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
        });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // 10 column headers per Component-AuditLog.md §10.
        var expectedHeaders = new[]
        {
            "OccurredAtUtc", "Site", "Channel", "Kind", "Status",
            "Target", "Actor", "DurationMs", "HttpStatus", "ErrorMessage",
        };
        foreach (var header in expectedHeaders)
        {
            Assert.Contains($"data-test=\"col-header-{header}\"", cut.Markup);
        }
    }

    [Fact]
    public void Click_NextPage_CallsService_WithCursor_OfLastRow()
    {
        // First page: two rows, descending by OccurredAtUtc. The grid must pass the
        // LAST row (the older one) back as the keyset cursor for the next page.
        var first = MakeEvent(new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc), AuditStatus.Delivered);
        var second = MakeEvent(new DateTime(2026, 5, 20, 11, 30, 0, DateTimeKind.Utc), AuditStatus.Failed);
        StubPage(new[] { first, second });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        cut.Find("[data-test=\"keyset-next\"]").Click();

        // Two service calls: initial + next.
        Assert.Equal(2, _calls.Count);
        var nextCall = _calls[1];
        Assert.NotNull(nextCall.Paging);
        Assert.Equal(second.OccurredAtUtc, nextCall.Paging!.AfterOccurredAtUtc);
        Assert.Equal(second.EventId, nextCall.Paging.AfterEventId);
    }

    [Fact]
    public void Click_Row_RaisesOnRowSelected()
    {
        var target = MakeEvent(DateTime.UtcNow.AddMinutes(-5), AuditStatus.Delivered);
        StubPage(new[] { target });

        AuditEventView? captured = null;
        var cut = Render<AuditResultsGrid>(p => p
            .Add(c => c.Filter, new AuditLogQueryFilter())
            .Add(c => c.OnRowSelected, EventCallback.Factory.Create<AuditEventView>(this, e => captured = e)));

        cut.Find($"[data-test=\"grid-row-{target.EventId}\"]").Click();

        Assert.NotNull(captured);
        Assert.Equal(target.EventId, captured!.EventId);
    }

    [Fact]
    public void Render_IncludesNodeColumn_BetweenSiteAndChannel()
    {
        // Task 15: the grid surfaces SourceNode in a dedicated "Node" column
        // positioned between Site and Channel.
        StubPage(new List<AuditEventView>
        {
            MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
        });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        Assert.Contains("data-test=\"col-header-Node\"", cut.Markup);

        // The header order must place Node between Site and Channel.
        var siteIdx = cut.Markup.IndexOf("data-test=\"col-header-Site\"", StringComparison.Ordinal);
        var nodeIdx = cut.Markup.IndexOf("data-test=\"col-header-Node\"", StringComparison.Ordinal);
        var channelIdx = cut.Markup.IndexOf("data-test=\"col-header-Channel\"", StringComparison.Ordinal);
        Assert.True(siteIdx < nodeIdx, "Node column must follow Site.");
        Assert.True(nodeIdx < channelIdx, "Node column must precede Channel.");
    }

    [Fact]
    public void Render_IncludesExecutionIdColumn()
    {
        StubPage(new List<AuditEventView>
        {
            MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
        });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // The ExecutionId column header is present alongside the spec columns.
        Assert.Contains("data-test=\"col-header-ExecutionId\"", cut.Markup);
    }

    [Fact]
    public void ExecutionId_NonNullRow_RendersShortMonospaceValue()
    {
        var executionId = Guid.Parse("abcdef01-2222-3333-4444-555555555555");
        var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: executionId);
        StubPage(new[] { row });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        var cell = cut.Find($"[data-test=\"execution-id-{row.EventId}\"]");
        // Short form: first 8 hex digits of the "N" form.
        Assert.Equal("abcdef01", cell.TextContent.Trim());
        // Monospace presentation; full value retained in the title attribute.
        Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
        Assert.Equal(executionId.ToString(), cell.GetAttribute("title"));
    }

    [Fact]
    public void ExecutionId_NullRow_RendersBlankPlaceholder_NoExecutionIdCell()
    {
        var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, executionId: null);
        StubPage(new[] { row });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // A null ExecutionId renders the em-dash placeholder, not a value cell.
        Assert.Empty(cut.FindAll($"[data-test=\"execution-id-{row.EventId}\"]"));
    }

    [Fact]
    public void Render_IncludesParentExecutionIdColumn()
    {
        StubPage(new List<AuditEventView>
        {
            MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered),
        });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // The ParentExecutionId column header is present alongside the spec columns.
        Assert.Contains("data-test=\"col-header-ParentExecutionId\"", cut.Markup);
    }

    [Fact]
    public void ParentExecutionId_NonNullRow_RendersShortMonospaceValue()
    {
        var parentExecutionId = Guid.Parse("fedcba98-2222-3333-4444-555555555555");
        var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: parentExecutionId);
        StubPage(new[] { row });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        var cell = cut.Find($"[data-test=\"parent-execution-id-{row.EventId}\"]");
        // Short form: first 8 hex digits of the "N" form — mirrors ExecutionId.
        Assert.Equal("fedcba98", cell.TextContent.Trim());
        // Monospace presentation; full value retained in the title attribute.
        Assert.Contains("font-monospace", cell.GetAttribute("class") ?? string.Empty);
        Assert.Equal(parentExecutionId.ToString(), cell.GetAttribute("title"));
    }

    [Fact]
    public void ParentExecutionId_NullRow_RendersBlankPlaceholder_NoParentExecutionIdCell()
    {
        var row = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered, parentExecutionId: null);
        StubPage(new[] { row });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // A null ParentExecutionId renders the em-dash placeholder, not a value cell.
        Assert.Empty(cut.FindAll($"[data-test=\"parent-execution-id-{row.EventId}\"]"));
    }

    [Fact]
    public void Status_FailedRow_HasErrorBadgeClass()
    {
        var failed = MakeEvent(DateTime.UtcNow.AddMinutes(-2), AuditStatus.Failed);
        var delivered = MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered);
        StubPage(new[] { delivered, failed });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // Failed badge => bg-danger (red). Delivered => bg-success (green).
        var failedBadge = cut.Find($"[data-test=\"status-badge-{failed.EventId}\"]");
        Assert.Contains("bg-danger", failedBadge.GetAttribute("class") ?? string.Empty);

        var deliveredBadge = cut.Find($"[data-test=\"status-badge-{delivered.EventId}\"]");
        Assert.Contains("bg-success", deliveredBadge.GetAttribute("class") ?? string.Empty);
    }

    // --- column resize + reorder UX (#23 follow-ups Task 10) ---------------
    //
    // The drag interaction itself is browser-side (audit-grid.js) and covered
    // by the Playwright suite. The bUnit tests below exercise the .NET-side
    // load/apply/persist logic that the JS callbacks drive: graceful handling
    // of stored orders, the reorder slot-move maths, and the resize minimum.

    /// <summary>Column keys in default (spec) order — the fallback used everywhere.</summary>
    private static readonly string[] DefaultOrder =
    {
        "OccurredAtUtc", "Site", "Channel", "Kind", "Status",
        "Target", "Actor", "ExecutionId", "ParentExecutionId",
        "DurationMs", "HttpStatus", "ErrorMessage",
    };

    private static int HeaderIndex(string markup, string key)
        => markup.IndexOf($"data-col-key=\"{key}\"", StringComparison.Ordinal);

    [Fact]
    public void Headers_RenderResizeHandleAndDragKey_ForEveryColumn()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        foreach (var key in DefaultOrder)
        {
            // Each <th> carries the stable drag key and a resize handle.
            Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
            Assert.Contains($"data-test=\"col-resize-{key}\"", cut.Markup);
        }
    }

    [Fact]
    public void ColumnOrderParameter_DrivesHeaderOrder()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });

        var cut = Render<AuditResultsGrid>(p => p
            .Add(c => c.Filter, new AuditLogQueryFilter())
            .Add(c => c.ColumnOrder, new[] { "Status", "Site" }));

        // Status + Site move to the front; the omitted columns still render,
        // appended in default order — Status precedes Site precedes Channel.
        Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
        Assert.True(HeaderIndex(cut.Markup, "Site") < HeaderIndex(cut.Markup, "Channel"));
        // No column is dropped — all ten headers are present.
        foreach (var key in DefaultOrder)
        {
            Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
        }
    }

    [Fact]
    public async Task OnColumnReordered_MovesColumnIntoTargetSlot_AndPersists()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // Drag Status onto OccurredAtUtc — Status should land in slot 0.
        await cut.InvokeAsync(() => cut.Instance.OnColumnReordered("Status", "OccurredAtUtc"));

        Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "OccurredAtUtc"));
        // The new order was persisted to sessionStorage under the order key.
        // Loose-mode JSInterop records every InvokeVoidAsync; find the save call.
        var save = JSInterop.Invocations
            .Single(i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnOrder");
        Assert.Contains("Status", (string)save.Arguments[1]!);
    }

    [Fact]
    public async Task OnColumnResized_BelowMinimum_ClampsTo64px_AndPersists()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // A drag that would shrink the column to 10px must clamp to the 64px floor.
        await cut.InvokeAsync(() => cut.Instance.OnColumnResized("Target", 10));

        // The clamped width is reflected as the --audit-col-width custom property.
        Assert.Contains("--audit-col-width: 64px", cut.Markup);
        // The width was persisted to sessionStorage under the widths key.
        Assert.Contains(JSInterop.Invocations,
            i => i.Identifier == "auditGrid.save" && (string)i.Arguments[0]! == "columnWidths");
    }

    [Fact]
    public void StoredOrder_WithUnknownKey_DegradesGracefully()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
        // A stale persisted order naming a removed column ("LegacyCol") plus a
        // subset of real columns — the unknown key must be dropped and the
        // omitted real columns appended in default order, never throwing.
        JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
            .SetResult("[\"Status\",\"LegacyCol\",\"Site\"]");
        JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
            .SetResult((string?)null);

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // Restored order applied: Status then Site at the front.
        Assert.True(HeaderIndex(cut.Markup, "Status") < HeaderIndex(cut.Markup, "Site"));
        // The unknown key produced no header and did not break rendering.
        Assert.DoesNotContain("LegacyCol", cut.Markup);
        // All ten real columns still present.
        foreach (var key in DefaultOrder)
        {
            Assert.Contains($"data-col-key=\"{key}\"", cut.Markup);
        }
    }

    [Fact]
    public void StoredWidths_ForUnknownColumn_AreIgnored()
    {
        StubPage(new[] { MakeEvent(DateTime.UtcNow.AddMinutes(-1), AuditStatus.Delivered) });
        JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnOrder")
            .SetResult((string?)null);
        // A width for a real column and one for a removed column.
        JSInterop.Setup<string?>("auditGrid.load", i => (string)i.Arguments[0]! == "columnWidths")
            .SetResult("{\"Target\":220,\"LegacyCol\":300}");

        var cut = Render<AuditResultsGrid>(p => p.Add(c => c.Filter, new AuditLogQueryFilter()));

        // The valid column's width was applied; the stale one silently ignored.
        Assert.Contains("--audit-col-width: 220px", cut.Markup);
        Assert.DoesNotContain("300px", cut.Markup);
    }
}
