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.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
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="AuditFilterBar"/> (#23 M7-T2 / Bundle B).
///
/// The bar carries the 10 spec filter elements plus the Errors-only toggle.
/// Channel is a single-select <c>&lt;select data-test="filter-channel-select"&gt;</c>;
/// Kind / Status / Site are
/// <see cref="ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared.MultiSelectDropdown{TValue}"/>
/// controls whose options are checkboxes tagged
/// <c>data-test="filter-&lt;dim&gt;-ms-opt-&lt;value&gt;"</c>. Tests pin:
/// (1) the full filter set renders; (2) Apply raises <c>OnFilterChanged</c> with
/// the selected values; (3) the Channel→Kind narrowing map drives Kind option
/// visibility; (4) the Errors-only toggle ORs the error statuses into Status when
/// Status is otherwise empty; (5) the "Last hour" preset populates <c>FromUtc</c>
/// to roughly an hour before "now" — proves the time-window collapse without
/// freezing the clock.
/// </summary>
public class AuditFilterBarTests : BunitContext
{
    private readonly ISiteRepository _siteRepo;
    private readonly IAuditLogQueryService _auditLogQueryService;

    public AuditFilterBarTests()
    {
        _siteRepo = Substitute.For<ISiteRepository>();
        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(new List<Site>
            {
                new("Plant A", "plant-a") { Id = 1 },
                new("Plant B", "plant-b") { Id = 2 },
            }));
        Services.AddSingleton(_siteRepo);

        // Task 15: the Node multi-select pulls its options from
        // IAuditLogQueryService.GetDistinctSourceNodesAsync. The default stub
        // returns the two central nodes the cluster uses; individual tests can
        // override via _auditLogQueryService.GetDistinctSourceNodesAsync(...).Returns(...).
        _auditLogQueryService = Substitute.For<IAuditLogQueryService>();
        _auditLogQueryService.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a", "central-b" }));
        _auditLogQueryService.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEventView>>(Array.Empty<AuditEventView>()));
        Services.AddSingleton(_auditLogQueryService);
    }

    [Fact]
    public void Render_AllTenElements_Plus_ErrorsOnlyToggle_Present()
    {
        var cut = Render<AuditFilterBar>();

        // Each filter element is tagged with a stable data-test attribute so the test
        // doesn't churn on cosmetic label changes.
        var markers = new[]
        {
            "data-test=\"filter-channel\"",
            "data-test=\"filter-kind\"",
            "data-test=\"filter-status\"",
            "data-test=\"filter-site\"",
            "data-test=\"filter-node\"",
            "data-test=\"filter-time-range\"",
            "data-test=\"filter-custom-range\"",
            "data-test=\"filter-instance\"",
            "data-test=\"filter-script\"",
            "data-test=\"filter-target\"",
            "data-test=\"filter-actor\"",
            "data-test=\"filter-execution-id\"",
            "data-test=\"filter-parent-execution-id\"",
            "data-test=\"filter-errors-only\"",
        };
        foreach (var marker in markers)
        {
            Assert.Contains(marker, cut.Markup);
        }
    }

    [Fact]
    public void Apply_RaisesOnFilterChanged_WithSelectedFilters()
    {
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // Drive UI: pick a Channel, type in the Target search box, click Apply.
        cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
        cut.Find("[data-test=\"filter-target\"] input").Change("Plant-A-OPC");
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.Equal(new[] { AuditChannel.ApiOutbound }, captured!.Channels);
        Assert.Equal("Plant-A-OPC", captured.Target);
    }

    [Fact]
    public void ChangingChannel_ReplacesTheSelection_SingleSelect()
    {
        // Channel is single-select: picking a second channel replaces the first
        // rather than adding to it (the page filters one channel at a time).
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");
        cut.Find("[data-test=\"filter-channel-select\"]").Change("Notification");
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.Equal(new[] { AuditChannel.Notification }, captured!.Channels);

        // Selecting "All channels" clears the channel filter entirely.
        cut.Find("[data-test=\"filter-channel-select\"]").Change(string.Empty);
        cut.Find("[data-test=\"filter-apply\"]").Click();
        Assert.Null(captured!.Channels);
    }

    [Fact]
    public void Channel_Narrows_Kind_Options_When_Selected()
    {
        var cut = Render<AuditFilterBar>();

        // With no Channel selected, every kind option is in the DOM.
        foreach (var kind in Enum.GetValues<AuditKind>())
        {
            Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
        }

        // Select ApiOutbound; Kind options outside the channel-kind map drop out.
        cut.Find("[data-test=\"filter-channel-select\"]").Change("ApiOutbound");

        var apiKinds = AuditQueryModel.KindsByChannel[AuditChannel.ApiOutbound];
        foreach (var kind in apiKinds)
        {
            Assert.Contains($"data-test=\"filter-kind-ms-opt-{kind}\"", cut.Markup);
        }
        // Sanity: an unrelated kind is gone.
        Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.NotifySend}\"", cut.Markup);
        Assert.DoesNotContain($"data-test=\"filter-kind-ms-opt-{AuditKind.InboundRequest}\"", cut.Markup);
    }

    [Fact]
    public void ErrorsOnly_Toggle_Adds_FailedParkedDiscarded_ToStatus_WhenStatusIsEmpty()
    {
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // Toggle Errors-only ON, leaving Status chips empty.
        cut.Find("[data-test=\"filter-errors-only\"] input").Change(true);
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        // Task 9: Errors-only targets the full non-success set {Failed, Parked, Discarded}.
        Assert.NotNull(captured!.Statuses);
        Assert.Equal(3, captured.Statuses!.Count);
        Assert.Contains(AuditStatus.Failed, captured.Statuses);
        Assert.Contains(AuditStatus.Parked, captured.Statuses);
        Assert.Contains(AuditStatus.Discarded, captured.Statuses);

        // Now pin an explicit Status option — Errors-only must yield (explicit wins).
        cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.Equal(new[] { AuditStatus.Delivered }, captured!.Statuses);
    }

    [Fact]
    public void NodeMultiSelect_RendersOptions_FromQueryService_AndMapsThroughToFilter()
    {
        // Task 15: the Node filter pulls its option set from
        // IAuditLogQueryService.GetDistinctSourceNodesAsync and threads the
        // chip selection into AuditLogQueryFilter.SourceNodes.
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // The bar marker plus the option checkboxes for the two cluster nodes
        // are present after init (the constructor stubs return two nodes).
        Assert.Contains("data-test=\"filter-node\"", cut.Markup);
        Assert.Contains("data-test=\"filter-node-ms-opt-central-a\"", cut.Markup);
        Assert.Contains("data-test=\"filter-node-ms-opt-central-b\"", cut.Markup);

        cut.Find("[data-test=\"filter-node-ms-opt-central-a\"]").Change(true);
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.NotNull(captured!.SourceNodes);
        Assert.Equal(new[] { "central-a" }, captured.SourceNodes);
    }

    [Fact]
    public void Apply_WithMultipleStatusChips_PassesAllSelectedStatuses()
    {
        // Task 9: multiple explicit Status chips all reach the filter — and they
        // win over the Errors-only default.
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        cut.Find("[data-test=\"filter-status-ms-opt-Delivered\"]").Change(true);
        cut.Find("[data-test=\"filter-status-ms-opt-Failed\"]").Change(true);
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.NotNull(captured!.Statuses);
        Assert.Equal(2, captured.Statuses!.Count);
        Assert.Contains(AuditStatus.Delivered, captured.Statuses);
        Assert.Contains(AuditStatus.Failed, captured.Statuses);
    }

    [Fact]
    public void Apply_WithPastedExecutionId_MapsThroughToFilter()
    {
        // The operator pastes a Guid into the Execution ID box; Apply must map it
        // straight onto AuditLogQueryFilter.ExecutionId.
        var executionId = Guid.Parse("99999999-8888-7777-6666-555555555555");
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        cut.Find("[data-test=\"filter-execution-id\"] input").Change(executionId.ToString());
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.Equal(executionId, captured!.ExecutionId);
    }

    [Fact]
    public void Apply_WithBlankOrUnparseableExecutionId_LeavesFilterExecutionIdNull()
    {
        // Lax parsing: a blank box yields no constraint; garbage text likewise.
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // Blank — never typed into.
        cut.Find("[data-test=\"filter-apply\"]").Click();
        Assert.NotNull(captured);
        Assert.Null(captured!.ExecutionId);

        // Unparseable paste — still dropped, no error.
        cut.Find("[data-test=\"filter-execution-id\"] input").Change("not-a-guid");
        cut.Find("[data-test=\"filter-apply\"]").Click();
        Assert.Null(captured!.ExecutionId);
    }

    [Fact]
    public void Apply_WithPastedParentExecutionId_MapsThroughToFilter()
    {
        // The operator pastes a Guid into the Parent execution ID box; Apply must
        // map it straight onto AuditLogQueryFilter.ParentExecutionId.
        var parentExecutionId = Guid.Parse("11112222-3333-4444-5555-666677778888");
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change(parentExecutionId.ToString());
        cut.Find("[data-test=\"filter-apply\"]").Click();

        Assert.NotNull(captured);
        Assert.Equal(parentExecutionId, captured!.ParentExecutionId);
    }

    [Fact]
    public void Apply_WithBlankOrUnparseableParentExecutionId_LeavesFilterParentExecutionIdNull()
    {
        // Lax parsing: a blank box yields no constraint; garbage text likewise.
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // Blank — never typed into.
        cut.Find("[data-test=\"filter-apply\"]").Click();
        Assert.NotNull(captured);
        Assert.Null(captured!.ParentExecutionId);

        // Unparseable paste — still dropped, no error.
        cut.Find("[data-test=\"filter-parent-execution-id\"] input").Change("not-a-guid");
        cut.Find("[data-test=\"filter-apply\"]").Click();
        Assert.Null(captured!.ParentExecutionId);
    }

    [Fact]
    public void TimeRange_LastHour_PopulatesFromUtc_ApproxOneHourAgo()
    {
        AuditLogQueryFilter? captured = null;
        var cut = Render<AuditFilterBar>(p => p
            .Add(c => c.OnFilterChanged, EventCallback.Factory.Create<AuditLogQueryFilter>(this, f => captured = f)));

        // LastHour is the default preset; clicking Apply must collapse it to FromUtc.
        var before = DateTime.UtcNow;
        cut.Find("[data-test=\"filter-apply\"]").Click();
        var after = DateTime.UtcNow;

        Assert.NotNull(captured);
        Assert.NotNull(captured!.FromUtc);
        // FromUtc should be in [now-1h-eps, now-1h+eps] computed against the Apply moment.
        var expectedLow = before.AddHours(-1).AddSeconds(-1);
        var expectedHigh = after.AddHours(-1).AddSeconds(1);
        Assert.InRange(captured.FromUtc!.Value, expectedLow, expectedHigh);
    }
}
