using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;

namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;

/// <summary>
/// HTTP-pipeline tests for the #23 M8 audit endpoints (<see cref="AuditEndpoints"/>).
///
/// <para>
/// ManagementService authenticates each request by hand (HTTP Basic → LDAP →
/// roles) rather than via the ASP.NET authorization-policy pipeline, so these
/// tests substitute <see cref="LdapAuthService"/> + <see cref="RoleMapper"/>
/// (both expose a virtual test seam) to drive the role outcome without standing
/// up a directory. The repository is a stubbed <see cref="IAuditLogRepository"/>.
/// </para>
/// </summary>
public class AuditEndpointsTests
{
    private const string BasicCredential = "auditor:password";

    // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory;
    // the endpoint serializes the canonical record (eventId + occurredAtUtc top-level,
    // domain fields out of DetailsJson) so these tests assert on the JSON/CSV output.
    private static AuditEvent SampleEvent(Guid? id = null, DateTime? occurredAt = null) =>
        ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: id ?? Guid.Parse("11111111-1111-1111-1111-111111111111"),
            occurredAtUtc: occurredAt ?? new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
            sourceSiteId: "plant-a",
            httpStatus: 200);

    /// <summary>
    /// Builds an in-process TestServer hosting the audit endpoints with stubbed
    /// auth + repository. <paramref name="roles"/> is the role set the
    /// substituted <see cref="RoleMapper"/> returns for the authenticated user;
    /// pass an empty array to simulate a user with no audit permission.
    /// </summary>
    private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync(
        string[] roles,
        IReadOnlyList<AuditEvent>[]? queryPages = null,
        bool ldapSucceeds = true)
    {
        var repo = Substitute.For<IAuditLogRepository>();
        if (queryPages is { Length: > 0 })
        {
            var returns = queryPages
                .Select(p => Task.FromResult<IReadOnlyList<AuditEvent>>(p))
                .ToArray();
            repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
                .Returns(returns[0], returns.Skip(1).ToArray());
        }
        else
        {
            repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
                .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
        }

        // Substituted LDAP bind — the shared ILdapAuthService is the seam now (Task 1.2).
        var ldap = Substitute.For<ILdapAuthService>();
        ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(ldapSucceeds
                ? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })
                : LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));

        // Substituted role mapper — MapGroupsToRolesAsync is virtual (test seam).
        var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
        roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
            .Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));

        var hostBuilder = new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddRouting();
                    services.AddSingleton(repo);
                    services.AddSingleton(ldap);
                    services.AddSingleton(roleMapper);
                    // ManagementAuthenticator now consults the login throttle before the bind.
                    services.AddSingleton(TimeProvider.System);
                    services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
                    services.AddSingleton<LoginThrottle>();
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
                });
            });

        var host = await hostBuilder.StartAsync();
        return (host.GetTestClient(), repo, host);
    }

    private static HttpRequestMessage Get(string url, string credential = BasicCredential)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, url);
        if (credential.Length > 0)
        {
            request.Headers.Authorization = new AuthenticationHeaderValue(
                "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
        }
        return request;
    }

    // ─────────────────────────────────────────────────────────────────────
    // /api/audit/query
    // ─────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Query_ValidParams_ReturnsJsonPage()
    {
        var (client, _, host) = await BuildHostAsync(
            roles: new[] { "Administrator" },
            queryPages: new[] { (IReadOnlyList<AuditEvent>)new[] { SampleEvent() } });
        using (host)
        {
            var response = await client.SendAsync(Get(
                "/api/audit/query?channel=ApiOutbound&status=Delivered&pageSize=100"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);

            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            var events = doc.RootElement.GetProperty("events");
            Assert.Equal(1, events.GetArrayLength());
            Assert.Equal("11111111-1111-1111-1111-111111111111",
                events[0].GetProperty("eventId").GetString());

            // A short page (1 row < pageSize 100) means no further pages.
            Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("nextCursor").ValueKind);
        }
    }

    [Fact]
    public async Task Query_WithCursor_ReturnsNextPage()
    {
        // First page is FULL (pageSize=2 → 2 rows) so the response carries a
        // non-null nextCursor; the test then replays that cursor as the next
        // request and asserts the repo saw a keyset-paged AuditLogPaging.
        var pageOne = (IReadOnlyList<AuditEvent>)new[]
        {
            SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"),
                new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc)),
            SampleEvent(Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"),
                new DateTime(2026, 5, 20, 11, 0, 0, DateTimeKind.Utc)),
        };
        var (client, repo, host) = await BuildHostAsync(
            roles: new[] { "Administrator" },
            queryPages: new[] { pageOne });
        using (host)
        {
            var first = await client.SendAsync(Get("/api/audit/query?pageSize=2"));
            Assert.Equal(HttpStatusCode.OK, first.StatusCode);

            using var firstDoc = JsonDocument.Parse(await first.Content.ReadAsStringAsync());
            var cursor = firstDoc.RootElement.GetProperty("nextCursor");
            Assert.Equal(JsonValueKind.Object, cursor.ValueKind);

            var afterEventId = cursor.GetProperty("afterEventId").GetString()!;
            var afterOccurredAt = cursor.GetProperty("afterOccurredAtUtc").GetString()!;
            Assert.Equal("aaaaaaaa-0000-0000-0000-000000000002", afterEventId);

            // Replay the cursor — the endpoint must thread it into AuditLogPaging.
            var second = await client.SendAsync(Get(
                $"/api/audit/query?pageSize=2&afterEventId={afterEventId}&afterOccurredAtUtc={Uri.EscapeDataString(afterOccurredAt)}"));
            Assert.Equal(HttpStatusCode.OK, second.StatusCode);

            await repo.Received().QueryAsync(
                Arg.Any<AuditLogQueryFilter>(),
                Arg.Is<AuditLogPaging>(p =>
                    p.PageSize == 2 &&
                    p.AfterEventId == Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002") &&
                    p.AfterOccurredAtUtc != null),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task Query_WithoutOperationalAudit_Returns403()
    {
        // A user whose only role is Designer holds neither OperationalAudit nor
        // AuditExport — the query endpoint must 403.
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Designer" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/query"));
            Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        }
    }

    [Fact]
    public async Task Query_WithoutCredentials_Returns401()
    {
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/query", credential: ""));
            Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        }
    }

    [Fact]
    public async Task Query_ViewerRole_IsAllowed()
    {
        // Viewer (post Task 1.7 home of the former AuditReadOnly role) satisfies
        // OperationalAudit (read) — query must succeed.
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/query"));
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // /api/audit/export
    // ─────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task Export_Csv_StreamsContent_WithCsvContentType()
    {
        var (client, _, host) = await BuildHostAsync(
            roles: new[] { "Administrator" },
            queryPages: new[]
            {
                (IReadOnlyList<AuditEvent>)new[] { SampleEvent() },
                (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
            });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export?format=csv"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);

            var disposition = response.Content.Headers.ContentDisposition;
            Assert.NotNull(disposition);
            Assert.Equal("attachment", disposition!.DispositionType);
            Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);

            var body = await response.Content.ReadAsStringAsync();
            Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
            Assert.Contains("11111111-1111-1111-1111-111111111111", body);
        }
    }

    [Fact]
    public async Task Export_Csv_DefaultsWhenFormatOmitted()
    {
        // No format= param → csv default.
        var (client, _, host) = await BuildHostAsync(
            roles: new[] { "Administrator" },
            queryPages: new[] { (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>() });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export"));
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);
        }
    }

    [Fact]
    public async Task Export_Jsonl_StreamsOnePerLine()
    {
        var (client, _, host) = await BuildHostAsync(
            roles: new[] { "Administrator" },
            queryPages: new[]
            {
                (IReadOnlyList<AuditEvent>)new[]
                {
                    SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000001")),
                    SampleEvent(Guid.Parse("bbbbbbbb-0000-0000-0000-000000000002")),
                },
                (IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
            });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export?format=jsonl"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Equal("application/x-ndjson", response.Content.Headers.ContentType!.MediaType);

            var body = await response.Content.ReadAsStringAsync();
            var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
            Assert.Equal(2, lines.Length);

            // Each line must be a standalone, parseable JSON object.
            foreach (var line in lines)
            {
                using var doc = JsonDocument.Parse(line);
                Assert.Equal(JsonValueKind.Object, doc.RootElement.ValueKind);
                Assert.True(doc.RootElement.TryGetProperty("eventId", out _));
            }
        }
    }

    [Fact]
    public async Task Export_Parquet_Returns501()
    {
        // Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
        // library is referenced, so the endpoint returns 501 with guidance.
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));

            Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
            var body = await response.Content.ReadAsStringAsync();
            Assert.Contains("Parquet export deferred to v1.x", body);
        }
    }

    [Fact]
    public async Task Export_WithoutAuditExport_Returns403()
    {
        // Viewer (former AuditReadOnly) grants read (OperationalAudit) but NOT
        // bulk export (AuditExport) — the export endpoint must 403. This is the
        // preserved half-SoD after the Task 1.7 AuditReadOnly→Viewer collapse.
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Viewer" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export?format=csv"));
            Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        }
    }

    [Fact]
    public async Task Export_UnsupportedFormat_Returns400()
    {
        var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/export?format=xml"));
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // Query-string parsing units
    // ─────────────────────────────────────────────────────────────────────

    [Fact]
    public void ParsePaging_ClampsPageSizeToMax()
    {
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["pageSize"] = "999999",
            });

        var paging = AuditEndpoints.ParsePaging(query);

        Assert.Equal(AuditEndpoints.MaxPageSize, paging.PageSize);
    }

    [Fact]
    public void ParseFilter_RepeatedParams_ParseIntoMultiValueLists()
    {
        // Repeated query params (channel=A&channel=B …) must widen to multi-value
        // filter lists — one element per supplied value.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["channel"] = new[] { "ApiOutbound", "DbOutbound" },
                ["kind"] = new[] { "ApiCall", "DbWrite" },
                ["status"] = new[] { "Failed", "Parked" },
                ["sourceSiteId"] = new[] { "plant-a", "plant-b" },
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.DbOutbound }, filter.Channels);
        Assert.Equal(new[] { AuditKind.ApiCall, AuditKind.DbWrite }, filter.Kinds);
        Assert.Equal(new[] { AuditStatus.Failed, AuditStatus.Parked }, filter.Statuses);
        Assert.Equal(new[] { "plant-a", "plant-b" }, filter.SourceSiteIds);
    }

    [Fact]
    public void ParseFilter_SingleParam_ParsesIntoOneElementList()
    {
        // The single-valued contract still holds — one param yields a
        // one-element list, not a scalar.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["channel"] = "ApiOutbound",
                ["status"] = "Delivered",
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Equal(new[] { AuditChannel.ApiOutbound }, filter.Channels);
        Assert.Equal(new[] { AuditStatus.Delivered }, filter.Statuses);
        Assert.Null(filter.Kinds);
        Assert.Null(filter.SourceSiteIds);
    }

    [Fact]
    public void ParseFilter_UnparseableValuesInRepeatedSet_AreDroppedSilently()
    {
        // Lax-parse contract: an unrecognised enum name is dropped, the rest of
        // the repeated set survives — no 400, no whole-set drop.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["channel"] = new[] { "ApiOutbound", "Bogus", "Notification" },
                ["status"] = new[] { "Nonsense" },
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Equal(new[] { AuditChannel.ApiOutbound, AuditChannel.Notification }, filter.Channels);
        // Every value unparseable → the dimension stays unconstrained (null).
        Assert.Null(filter.Statuses);
    }

    [Fact]
    public void ParseFilter_ExecutionId_ParsesIntoSingleValueGuid()
    {
        // executionId is a single-value Guid? filter — mirrors correlationId.
        var executionId = Guid.NewGuid();
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["executionId"] = executionId.ToString(),
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Equal(executionId, filter.ExecutionId);
    }

    [Fact]
    public void ParseFilter_UnparseableExecutionId_IsDroppedSilently()
    {
        // Lax-parse contract: an unparseable executionId is dropped (no 400) —
        // mirrors the correlationId parse.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["executionId"] = "not-a-guid",
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Null(filter.ExecutionId);
    }

    [Fact]
    public void ParseFilter_ParentExecutionId_ParsesIntoSingleValueGuid()
    {
        // parentExecutionId is a single-value Guid? filter — mirrors executionId.
        var parentExecutionId = Guid.NewGuid();
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["parentExecutionId"] = parentExecutionId.ToString(),
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Equal(parentExecutionId, filter.ParentExecutionId);
    }

    [Fact]
    public void ParseFilter_UnparseableParentExecutionId_IsDroppedSilently()
    {
        // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400) —
        // mirrors the executionId parse.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["parentExecutionId"] = "not-a-guid",
            });

        var filter = AuditEndpoints.ParseFilter(query);

        Assert.Null(filter.ParentExecutionId);
    }

    [Fact]
    public async Task Query_RepeatedChannelParams_ReachRepositoryAsMultiValueFilter()
    {
        // End-to-end: a repeated channel= query param must surface at the
        // repository as a two-element Channels list.
        var (client, repo, host) = await BuildHostAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get(
                "/api/audit/query?channel=ApiOutbound&channel=DbOutbound"));
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f =>
                    f.Channels != null && f.Channels.Count == 2 &&
                    f.Channels.Contains(AuditChannel.ApiOutbound) &&
                    f.Channels.Contains(AuditChannel.DbOutbound)),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public void ParsePaging_HalfSuppliedCursor_IsDropped()
    {
        // afterEventId without afterOccurredAtUtc is an invalid keyset cursor —
        // both must be dropped so the repository gets a first-page request.
        var query = new Microsoft.AspNetCore.Http.QueryCollection(
            new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
            {
                ["afterEventId"] = Guid.NewGuid().ToString(),
            });

        var paging = AuditEndpoints.ParsePaging(query);

        Assert.Null(paging.AfterEventId);
        Assert.Null(paging.AfterOccurredAtUtc);
    }

    // ─────────────────────────────────────────────────────────────────────
    // ApplySiteScope (Management-019)
    // ─────────────────────────────────────────────────────────────────────

    [Fact]
    public void ApplySiteScope_SystemWideUser_ReturnsFilterUnchanged()
    {
        // Empty PermittedSiteIds is the system-wide signal (Administrator,
        // system-wide Deployer, audit roles with no scope rules attached). The
        // filter should pass through with no restriction added.
        var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
            "alice", "Alice", new[] { "Administrator" }, Array.Empty<string>());
        var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });

        var result = AuditEndpoints.ApplySiteScope(filter, user);

        Assert.NotNull(result);
        Assert.Same(filter, result);
    }

    [Fact]
    public void ApplySiteScope_ScopedUser_EmptyCallerFilter_RestrictedToPermittedSet()
    {
        // No explicit sourceSiteId from the caller — the helper must restrict
        // the query to the user's permitted set, otherwise a site-scoped audit
        // user could read every site's rows.
        var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
            "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
        var filter = new AuditLogQueryFilter();

        var result = AuditEndpoints.ApplySiteScope(filter, user);

        Assert.NotNull(result);
        Assert.NotNull(result!.SourceSiteIds);
        Assert.Equal(new[] { "plant-a", "plant-b" }, result.SourceSiteIds!.OrderBy(s => s).ToArray());
    }

    [Fact]
    public void ApplySiteScope_ScopedUser_ExplicitInScopeFilter_KeptVerbatim()
    {
        var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
            "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a", "plant-b" });
        var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a" });

        var result = AuditEndpoints.ApplySiteScope(filter, user);

        Assert.NotNull(result);
        Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
    }

    [Fact]
    public void ApplySiteScope_ScopedUser_ExplicitOutOfScopeFilter_ReturnsNull()
    {
        // Caller explicitly asked for a site they cannot see — the helper signals
        // "403" by returning null rather than silently producing an empty page.
        var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
            "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
        var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-b" });

        var result = AuditEndpoints.ApplySiteScope(filter, user);

        Assert.Null(result);
    }

    [Fact]
    public void ApplySiteScope_ScopedUser_MixedInAndOutOfScopeFilter_IntersectedToInScopeOnly()
    {
        var user = new ZB.MOM.WW.ScadaBridge.Commons.Messages.Management.AuthenticatedUser(
            "alice", "Alice", new[] { "Viewer" }, new[] { "plant-a" });
        var filter = new AuditLogQueryFilter(SourceSiteIds: new[] { "plant-a", "plant-b" });

        var result = AuditEndpoints.ApplySiteScope(filter, user);

        Assert.NotNull(result);
        Assert.Equal(new[] { "plant-a" }, result!.SourceSiteIds);
    }

    // ─────────────────────────────────────────────────────────────────────
    // /api/audit/tree
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Builds a TestServer with the audit-log endpoints wired up and the repository
    /// stub returning the supplied <paramref name="treeNodes"/> for
    /// <c>GetExecutionTreeAsync</c>.
    /// </summary>
    private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithTreeAsync(
        string[] roles,
        IReadOnlyList<ExecutionTreeNode>? treeNodes = null)
    {
        var repo = Substitute.For<IAuditLogRepository>();

        // Default QueryAsync stub so the shared host initialisation does not fail.
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var returnNodes = treeNodes ?? Array.Empty<ExecutionTreeNode>();
        repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(returnNodes));

        var ldap = Substitute.For<ILdapAuthService>();
        ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" }));

        var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
        roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
            .Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));

        var hostBuilder = new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddRouting();
                    services.AddSingleton(repo);
                    services.AddSingleton(ldap);
                    services.AddSingleton(roleMapper);
                    // ManagementAuthenticator now consults the login throttle before the bind.
                    services.AddSingleton(TimeProvider.System);
                    services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
                    services.AddSingleton<LoginThrottle>();
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
                });
            });

        var host = await hostBuilder.StartAsync();
        return (host.GetTestClient(), repo, host);
    }

    private static ExecutionTreeNode MakeNode(Guid id, Guid? parentId = null, int rowCount = 2) =>
        new ExecutionTreeNode(
            ExecutionId: id,
            ParentExecutionId: parentId,
            RowCount: rowCount,
            Channels: new[] { "ApiOutbound" },
            Statuses: new[] { "Delivered" },
            SourceSiteId: "plant-a",
            SourceInstanceId: "inst-1",
            FirstOccurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
            LastOccurredAtUtc: new DateTime(2026, 5, 20, 10, 1, 0, DateTimeKind.Utc));

    [Fact]
    public async Task Tree_ValidExecutionId_ReturnsJsonArray()
    {
        var root = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001");
        var child = Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002");
        var nodes = new[]
        {
            MakeNode(root),
            MakeNode(child, parentId: root),
        };

        var (client, repo, host) = await BuildHostWithTreeAsync(
            roles: new[] { "Administrator" },
            treeNodes: nodes);
        using (host)
        {
            var response = await client.SendAsync(Get($"/api/audit/tree?executionId={root:D}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            Assert.Equal("application/json", response.Content.Headers.ContentType!.MediaType);

            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
            Assert.Equal(2, doc.RootElement.GetArrayLength());

            await repo.Received(1).GetExecutionTreeAsync(root, Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task Tree_RepoReturnsEmpty_ReturnsEmptyArray()
    {
        var id = Guid.NewGuid();
        var (client, _, host) = await BuildHostWithTreeAsync(
            roles: new[] { "Administrator" },
            treeNodes: Array.Empty<ExecutionTreeNode>());
        using (host)
        {
            var response = await client.SendAsync(Get($"/api/audit/tree?executionId={id:D}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
            Assert.Equal(0, doc.RootElement.GetArrayLength());
        }
    }

    [Fact]
    public async Task Tree_MissingExecutionId_Returns400()
    {
        var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/tree"));

            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }
    }

    [Fact]
    public async Task Tree_InvalidExecutionId_Returns400()
    {
        var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get("/api/audit/tree?executionId=not-a-guid"));

            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
            var body = await response.Content.ReadAsStringAsync();
            Assert.Contains("BAD_REQUEST", body);
        }
    }

    [Fact]
    public async Task Tree_WithoutOperationalAudit_Returns403()
    {
        var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Designer" });
        using (host)
        {
            var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));

            Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        }
    }

    [Fact]
    public async Task Tree_WithoutCredentials_Returns401()
    {
        var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}", credential: ""));

            Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        }
    }

    [Fact]
    public async Task Tree_ViewerRole_IsAllowed()
    {
        var (client, _, host) = await BuildHostWithTreeAsync(roles: new[] { "Viewer" });
        using (host)
        {
            var response = await client.SendAsync(Get($"/api/audit/tree?executionId={Guid.NewGuid():D}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        }
    }

    // ─────────────────────────────────────────────────────────────────────
    // POST /api/audit/backfill-source-node (M5.6 T5)
    // ─────────────────────────────────────────────────────────────────────

    private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostWithBackfillAsync(
        string[] roles,
        long backfillResult = 42L,
        bool ldapSucceeds = true)
    {
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
        repo.BackfillSourceNodeAsync(
                Arg.Any<string>(), Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult(backfillResult));
        repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>>(
                Array.Empty<ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.ExecutionTreeNode>()));

        var ldap = Substitute.For<ILdapAuthService>();
        ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Returns(ldapSucceeds
                ? LdapAuthResult.Success("auditor", "Auditor", new[] { "audit" })
                : LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));

        var roleMapper = Substitute.For<RoleMapper>(Substitute.For<ISecurityRepository>());
        roleMapper.MapGroupsToRolesAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
            .Returns(new RoleMappingResult(roles, Array.Empty<string>(), IsSystemWideDeployment: true));

        var hostBuilder = new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddRouting();
                    services.AddSingleton(repo);
                    services.AddSingleton(ldap);
                    services.AddSingleton(roleMapper);
                    // ManagementAuthenticator now consults the login throttle before the bind.
                    services.AddSingleton(TimeProvider.System);
                    services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
                    services.AddSingleton<LoginThrottle>();
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseEndpoints(endpoints => endpoints.MapAuditAPI());
                });
            });

        var host = await hostBuilder.StartAsync();
        return (host.GetTestClient(), repo, host);
    }

    private static HttpRequestMessage Post(string url, string body, string credential = BasicCredential)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, url)
        {
            Content = new StringContent(body, Encoding.UTF8, "application/json"),
        };
        if (credential.Length > 0)
        {
            request.Headers.Authorization = new AuthenticationHeaderValue(
                "Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(credential)));
        }
        return request;
    }

    [Fact]
    public async Task BackfillSourceNode_AdminRole_Returns200WithRowCount()
    {
        var (client, _, host) = await BuildHostWithBackfillAsync(
            roles: new[] { "Administrator" }, backfillResult: 12345L);
        using (host)
        {
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
            var root = doc.RootElement;
            Assert.Equal(12345L, root.GetProperty("rowsUpdated").GetInt64());
            Assert.Equal("unknown", root.GetProperty("sentinel").GetString());
        }
    }

    [Fact]
    public async Task BackfillSourceNode_ViewerRole_Returns403()
    {
        // Viewer has OperationalAudit but NOT the Admin-only backfill permission.
        var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Viewer" });
        using (host)
        {
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}"));

            Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
        }
    }

    [Fact]
    public async Task BackfillSourceNode_NoCredentials_Returns401()
    {
        var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"unknown\",\"before\":\"2026-01-01T00:00:00Z\"}",
                credential: ""));

            Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
        }
    }

    [Fact]
    public async Task BackfillSourceNode_MissingBefore_Returns400()
    {
        var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
        using (host)
        {
            // No "before" field — required.
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"unknown\"}"));

            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }
    }

    [Fact]
    public async Task BackfillSourceNode_InvalidBeforeDate_Returns400()
    {
        var (client, _, host) = await BuildHostWithBackfillAsync(roles: new[] { "Administrator" });
        using (host)
        {
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"unknown\",\"before\":\"not-a-date\"}"));

            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }
    }

    [Fact]
    public async Task BackfillSourceNode_CustomSentinelAndBatch_PassedToRepo()
    {
        var (client, repo, host) = await BuildHostWithBackfillAsync(
            roles: new[] { "Administrator" }, backfillResult: 7L);
        using (host)
        {
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"sentinel\":\"pre-feature\",\"before\":\"2026-01-01T00:00:00Z\",\"batchSize\":2000}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            await repo.Received(1).BackfillSourceNodeAsync(
                "pre-feature",
                Arg.Is<DateTime>(d => d.Year == 2026 && d.Month == 1 && d.Day == 1),
                2000,
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task BackfillSourceNode_DefaultSentinel_IsUnknown_WhenOmitted()
    {
        var (client, repo, host) = await BuildHostWithBackfillAsync(
            roles: new[] { "Administrator" }, backfillResult: 0L);
        using (host)
        {
            // Omit "sentinel" — endpoint defaults to "unknown".
            var response = await client.SendAsync(Post(
                "/api/audit/backfill-source-node",
                "{\"before\":\"2026-01-01T00:00:00Z\"}"));

            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            await repo.Received(1).BackfillSourceNodeAsync(
                "unknown",
                Arg.Any<DateTime>(),
                Arg.Any<int>(),
                Arg.Any<CancellationToken>());
        }
    }
}
