using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;

/// <summary>
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
/// these tests pin the filter forwarding contract and the 100-row default-page-size
/// rule the grid relies on.
/// </summary>
public class AuditLogQueryServiceTests
{
    private static ICentralHealthAggregator EmptyAggregator()
    {
        var agg = Substitute.For<ICentralHealthAggregator>();
        agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
        return agg;
    }

    [Fact]
    public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
        var paging = new AuditLogPaging(PageSize: 25);
        // C3 (Task 2.5): the repository returns canonical ZB.MOM.WW.Audit.AuditEvent rows;
        // the service decomposes each into a flat AuditEventView for the UI.
        var eventId = Guid.NewGuid();
        var repoRows = new List<AuditEvent>
        {
            ScadaBridgeAuditEventFactory.Create(
                channel: AuditChannel.ApiOutbound,
                kind: AuditKind.ApiCall,
                status: AuditStatus.Delivered,
                eventId: eventId),
        };
        repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(repoRows));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());

        var result = await sut.QueryAsync(filter, paging);

        // The service projects canonical → AuditEventView (a new list), so assert on
        // the decomposed content rather than reference identity.
        var view = Assert.Single(result);
        Assert.Equal(eventId, view.EventId);
        Assert.Equal(AuditChannel.ApiOutbound, view.Channel);
        Assert.Equal(AuditKind.ApiCall, view.Kind);
        Assert.Equal(AuditStatus.Delivered, view.Status);
        await repo.Received(1).QueryAsync(filter, paging, Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        AuditLogPaging? observed = null;
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());

        await sut.QueryAsync(new AuditLogQueryFilter(), paging: null);

        Assert.NotNull(observed);
        Assert.Equal(sut.DefaultPageSize, observed!.PageSize);
        Assert.Equal(100, sut.DefaultPageSize);
        Assert.Null(observed.AfterOccurredAtUtc);
        Assert.Null(observed.AfterEventId);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc);
        var repoSnapshot = new AuditLogKpiSnapshot(
            TotalEventsLastHour: 42,
            ErrorEventsLastHour: 7,
            BacklogTotal: 0, // repo leaves this at zero
            AsOfUtc: anchor);
        repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult(repoSnapshot));

        // Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11.
        // Sum = 16 → backlog tile shows 16.
        var sites = new Dictionary<string, SiteHealthState>
        {
            ["plant-a"] = StateWithBacklog("plant-a", pending: 5),
            ["plant-b"] = StateWithBacklog("plant-b", pending: 11),
        };
        var agg = Substitute.For<ICentralHealthAggregator>();
        agg.GetAllSiteStates().Returns(sites);

        var sut = new AuditLogQueryService(repo, agg);

        var snapshot = await sut.GetKpiSnapshotAsync();

        Assert.Equal(42, snapshot.TotalEventsLastHour);
        Assert.Equal(7, snapshot.ErrorEventsLastHour);
        Assert.Equal(16, snapshot.BacklogTotal);
        Assert.Equal(anchor, snapshot.AsOfUtc);

        // The service requests a 1-hour trailing window and lets the repo
        // anchor nowUtc to its own clock — we leave the second parameter null.
        await repo.Received(1).GetKpiSnapshotAsync(
            TimeSpan.FromHours(1),
            Arg.Is<DateTime?>(v => v == null),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow)));

        // plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog.
        var sites = new Dictionary<string, SiteHealthState>
        {
            ["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true },
            ["plant-b"] = StateWithBacklog("plant-b", pending: null),
            ["plant-c"] = StateWithBacklog("plant-c", pending: 4),
        };
        var agg = Substitute.For<ICentralHealthAggregator>();
        agg.GetAllSiteStates().Returns(sites);

        var sut = new AuditLogQueryService(repo, agg);

        var snapshot = await sut.GetKpiSnapshotAsync();

        // Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero.
        Assert.Equal(4, snapshot.BacklogTotal);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // #23 M7 — DbContext concurrency race regression (Bundle H follow-up)
    //
    // The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized
    // auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME
    // scoped Blazor-circuit ScadaBridgeDbContext. EF Core then throws
    // "A second operation was started on this context instance before a previous
    // operation completed." AuditLogQueryService now opens its OWN DI scope per
    // QueryAsync call (scope-per-query) so it never shares the page's scoped
    // context — these tests pin that contract.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace()
    {
        // A real ScadaBridgeDbContext (SQLite in-memory) registered as SCOPED with
        // the real AuditLogRepository — exactly the shared-scoped-context shape
        // that produces the EF race when one context services two operations.
        using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:");
        connection.Open();

        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDbContext<ScadaBridgeDbContext>(options =>
            options.UseSqlite(connection)
                   .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
        services.AddScoped<IAuditLogRepository, AuditLogRepository>();

        await using var provider = services.BuildServiceProvider();

        // Create the schema once on a throwaway scope.
        using (var seedScope = provider.CreateScope())
        {
            var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
            await ctx.Database.EnsureCreatedAsync();
        }

        var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
        var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());

        var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });

        // Fire two QueryAsync calls in parallel. With scope-per-query each gets a
        // fresh DbContext, so this completes cleanly; with a shared scoped context
        // EF throws "A second operation was started on this context instance".
        var t1 = sut.QueryAsync(filter);
        var t2 = sut.QueryAsync(filter);

        var results = await Task.WhenAll(t1, t2);

        Assert.All(results, Assert.NotNull);
    }

    [Fact]
    public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls()
    {
        // Two sequential calls must each resolve the repository from a distinct
        // scope — the service must never cache a single repository instance.
        var resolvedRepos = new List<IAuditLogRepository>();

        var services = new ServiceCollection();
        services.AddScoped<IAuditLogRepository>(_ =>
        {
            var repo = Substitute.For<IAuditLogRepository>();
            repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
            resolvedRepos.Add(repo);
            return repo;
        });

        await using var provider = services.BuildServiceProvider();
        var sut = new AuditLogQueryService(
            provider.GetRequiredService<IServiceScopeFactory>(),
            EmptyAggregator());

        await sut.QueryAsync(new AuditLogQueryFilter());
        await sut.QueryAsync(new AuditLogQueryFilter());

        // Each QueryAsync opened its own scope → two distinct repo instances.
        Assert.Equal(2, resolvedRepos.Count);
        Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
    // a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
    // QueryAsync's scope-per-call contract on the production path.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
        var expected = new List<ExecutionTreeNode>
        {
            new(executionId, null, 3,
                new[] { "ApiOutbound" }, new[] { "Delivered" },
                "plant-a", "boiler-3",
                new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
                new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
        };
        repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());

        var result = await sut.GetExecutionTreeAsync(executionId);

        Assert.Same(expected, result);
        await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
    {
        // The production ctor must resolve a fresh repository per call — same
        // scope-per-query contract QueryAsync upholds, so the page's auto-load
        // never shares the circuit-scoped DbContext.
        var resolvedRepos = new List<IAuditLogRepository>();

        var services = new ServiceCollection();
        services.AddScoped<IAuditLogRepository>(_ =>
        {
            var repo = Substitute.For<IAuditLogRepository>();
            repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
            resolvedRepos.Add(repo);
            return repo;
        });

        await using var provider = services.BuildServiceProvider();
        var sut = new AuditLogQueryService(
            provider.GetRequiredService<IServiceScopeFactory>(),
            EmptyAggregator());

        await sut.GetExecutionTreeAsync(Guid.NewGuid());
        await sut.GetExecutionTreeAsync(Guid.NewGuid());

        Assert.Equal(2, resolvedRepos.Count);
        Assert.NotSame(resolvedRepos[0], resolvedRepos[1]);
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Task 15: SourceNode filter forwarding + distinct-nodes service contract.
    // ─────────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task QueryAsync_ForwardsSourceNodesFilter_ToRepository()
    {
        // The Audit Log page's new Node multi-select pushes its chip set into
        // AuditLogQueryFilter.SourceNodes; the service must thread it through
        // unchanged so the repository's IN-list applies.
        var repo = Substitute.For<IAuditLogRepository>();
        var filter = new AuditLogQueryFilter(
            SourceNodes: new[] { "central-a", "site-plant-a-node-a" });
        repo.QueryAsync(filter, Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());
        await sut.QueryAsync(filter);

        await repo.Received(1).QueryAsync(
            Arg.Is<AuditLogQueryFilter>(f =>
                f.SourceNodes != null
                && f.SourceNodes.Count == 2
                && f.SourceNodes.Contains("central-a")
                && f.SourceNodes.Contains("site-plant-a-node-a")),
            Arg.Any<AuditLogPaging>(),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task GetDistinctSourceNodesAsync_ForwardsToRepository_OnFirstCall()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var expected = new[] { "central-a", "central-b", "site-plant-a-node-a" };
        repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<string>>(expected));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());

        var result = await sut.GetDistinctSourceNodesAsync();

        Assert.Equal(expected, result);
        await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task GetDistinctSourceNodesAsync_CachesSnapshot_AcrossRepeatedCalls()
    {
        // Two back-to-back calls within the 60s TTL must hit the repository
        // exactly once — the filter bar should never produce N DB hits when
        // the operator opens it twice in quick succession.
        var repo = Substitute.For<IAuditLogRepository>();
        repo.GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<string>>(new[] { "central-a" }));

        var sut = new AuditLogQueryService(repo, EmptyAggregator());

        var first = await sut.GetDistinctSourceNodesAsync();
        var second = await sut.GetDistinctSourceNodesAsync();

        Assert.Equal(first, second);
        await repo.Received(1).GetDistinctSourceNodesAsync(Arg.Any<CancellationToken>());
    }

    private static SiteHealthState StateWithBacklog(string siteId, int? pending)
    {
        SiteAuditBacklogSnapshot? backlog = pending.HasValue
            ? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0)
            : null;
        var report = new SiteHealthReport(
            SiteId: siteId,
            SequenceNumber: 1,
            ReportTimestamp: DateTimeOffset.UtcNow,
            DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
            TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
            ScriptErrorCount: 0,
            AlarmEvaluationErrorCount: 0,
            StoreAndForwardBufferDepths: new Dictionary<string, int>(),
            DeadLetterCount: 0,
            DeployedInstanceCount: 0,
            EnabledInstanceCount: 0,
            DisabledInstanceCount: 0,
            SiteAuditBacklog: backlog);
        return new SiteHealthState
        {
            SiteId = siteId,
            LatestReport = report,
            LastReportReceivedAt = DateTimeOffset.UtcNow,
            LastHeartbeatAt = DateTimeOffset.UtcNow,
            LastSequenceNumber = 1,
            IsOnline = true,
        };
    }
}
