using System.Net;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
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.CentralUI.Audit;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
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.ScadaBridge.Security;

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

/// <summary>
/// Endpoint-level tests for the Audit Log CSV export (#23 M7-T14 / Bundle F).
///
/// <para>
/// CentralUI uses minimal-API endpoints (see <c>AuthEndpoints</c> /
/// <c>ScriptAnalysisEndpoints</c>) rather than MVC controllers, so this brief's
/// "controller" is implemented as <see cref="AuditExportEndpoints"/>. The tests
/// pin two things: (a) the <c>GET /api/centralui/audit/export</c> route sets
/// the correct content-type + attachment disposition + body, and (b) the
/// query-string is parsed into an <see cref="AuditLogQueryFilter"/> and handed
/// to <see cref="IAuditLogExportService"/>.
/// </para>
/// </summary>
public class AuditExportEndpointsTests
{
    // C3 (Task 2.5): the export endpoint reads canonical ZB.MOM.WW.Audit.AuditEvent
    // rows straight off IAuditLogRepository.QueryAsync; build them via the factory.
    private static AuditEvent SampleEvent() =>
        ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCall,
            status: AuditStatus.Delivered,
            eventId: Guid.Parse("11111111-1111-1111-1111-111111111111"),
            occurredAtUtc: new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
            sourceSiteId: "plant-a",
            httpStatus: 200);

    /// <summary>
    /// Builds a tiny in-process test host that wires the export endpoint to a
    /// stubbed <see cref="IAuditLogRepository"/>. Returns a ready-to-call
    /// <see cref="HttpClient"/> and the repo substitute so the test can assert
    /// on what the endpoint did.
    /// </summary>
    private static async Task<(HttpClient Client, IAuditLogRepository Repo, IHost Host)> BuildHostAsync()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
            .Returns(
                Task.FromResult<IReadOnlyList<AuditEvent>>(new[] { SampleEvent() }),
                Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));

        var hostBuilder = new HostBuilder()
            .ConfigureWebHost(web =>
            {
                web.UseTestServer();
                web.ConfigureServices(services =>
                {
                    services.AddRouting();
                    // The endpoint is AuditExport-gated (#23 M7-T15 Bundle G);
                    // the tests run as pre-authenticated principals built by
                    // FakeAuthHandler (everyone has the Admin role), which is
                    // one of AuditExportRoles, so the policy succeeds.
                    services.AddAuthentication(FakeAuthHandler.SchemeName)
                        .AddScheme<Microsoft.AspNetCore.Authentication.AuthenticationSchemeOptions, FakeAuthHandler>(
                            FakeAuthHandler.SchemeName, _ => { });
                    // Use the real production policy wiring so the endpoint's
                    // updated AuditExport gate (#23 M7-T15 Bundle G) is what
                    // the tests exercise. The fake principal carries the
                    // "Administrator" role, which AuditExportRoles permits.
                    services.AddScadaBridgeAuthorization();
                    services.AddSingleton(repo);
                    services.AddScoped<IAuditLogExportService, AuditLogExportService>();
                });
                web.Configure(app =>
                {
                    app.UseRouting();
                    app.UseAuthentication();
                    app.UseAuthorization();
                    app.UseEndpoints(endpoints =>
                    {
                        endpoints.MapAuditExportEndpoints();
                    });
                });
            });

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

    [Fact]
    public async Task ExportEndpoint_Get_ReturnsCsvContentType_AndAttachmentDisposition()
    {
        var (client, _, host) = await BuildHostAsync();
        using (host)
        {
            var response = await client.GetAsync("/api/centralui/audit/export");

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

            // Content-Type: text/csv (charset may or may not be present).
            Assert.NotNull(response.Content.Headers.ContentType);
            Assert.Equal("text/csv", response.Content.Headers.ContentType!.MediaType);

            // Content-Disposition: attachment with a *.csv filename.
            ContentDispositionHeaderValue? disposition = response.Content.Headers.ContentDisposition;
            Assert.NotNull(disposition);
            Assert.Equal("attachment", disposition!.DispositionType);
            Assert.NotNull(disposition.FileName);
            Assert.EndsWith(".csv", disposition.FileName, StringComparison.OrdinalIgnoreCase);

            // Body starts with the header row and contains the sample row.
            var body = await response.Content.ReadAsStringAsync();
            Assert.StartsWith("EventId,OccurredAtUtc,IngestedAtUtc,", body);
            Assert.Contains("11111111-1111-1111-1111-111111111111", body);
        }
    }

    [Fact]
    public async Task ExportEndpoint_PassesFilterFromQueryString_ToService()
    {
        var (client, repo, host) = await BuildHostAsync();
        using (host)
        {
            var correlationId = Guid.NewGuid().ToString();
            var executionId = Guid.NewGuid().ToString();
            var parentExecutionId = Guid.NewGuid().ToString();
            var url =
                "/api/centralui/audit/export?" +
                "channel=ApiOutbound&" +
                "kind=ApiCall&" +
                "status=Failed&" +
                "site=plant-a&" +
                "target=PaymentApi&" +
                "actor=apikey-1&" +
                $"correlationId={correlationId}&" +
                $"executionId={executionId}&" +
                $"parentExecutionId={parentExecutionId}&" +
                "from=2026-05-20T00:00:00Z&" +
                "to=2026-05-20T23:59:59Z";

            var response = await client.GetAsync(url);
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);

            // Read the body to ensure the streaming response is fully drained
            // before we assert on the repo substitute (the test server flushes
            // the endpoint pipeline on response read).
            _ = await response.Content.ReadAsStringAsync();

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f =>
                    f.Channels != null && f.Channels.Count == 1 && f.Channels[0] == AuditChannel.ApiOutbound &&
                    f.Kinds != null && f.Kinds.Count == 1 && f.Kinds[0] == AuditKind.ApiCall &&
                    f.Statuses != null && f.Statuses.Count == 1 && f.Statuses[0] == AuditStatus.Failed &&
                    f.SourceSiteIds != null && f.SourceSiteIds.Count == 1 && f.SourceSiteIds[0] == "plant-a" &&
                    f.Target == "PaymentApi" &&
                    f.Actor == "apikey-1" &&
                    f.CorrelationId == Guid.Parse(correlationId) &&
                    f.ExecutionId == Guid.Parse(executionId) &&
                    f.ParentExecutionId == Guid.Parse(parentExecutionId) &&
                    f.FromUtc == new DateTime(2026, 5, 20, 0, 0, 0, DateTimeKind.Utc) &&
                    f.ToUtc == new DateTime(2026, 5, 20, 23, 59, 59, DateTimeKind.Utc)),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task ExportEndpoint_NoQueryString_PassesEmptyFilter()
    {
        // Sanity: a bare GET (no params) yields a filter with every column null
        // — i.e. an unconstrained export.
        var (client, repo, host) = await BuildHostAsync();
        using (host)
        {
            var response = await client.GetAsync("/api/centralui/audit/export");
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            _ = await response.Content.ReadAsStringAsync();

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f =>
                    f.Channels == null &&
                    f.Kinds == null &&
                    f.Statuses == null &&
                    f.SourceSiteIds == null &&
                    f.Target == null &&
                    f.Actor == null &&
                    f.CorrelationId == null &&
                    f.ExecutionId == null &&
                    f.ParentExecutionId == null &&
                    f.FromUtc == null &&
                    f.ToUtc == null),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task ExportEndpoint_UnknownEnumValue_SilentlyIgnored()
    {
        // Defensive parsing: a junk channel value MUST NOT 500 the export —
        // mirrors the page-level query-string parser (#23 M7 Bundle D) which
        // silently drops unrecognised values.
        var (client, repo, host) = await BuildHostAsync();
        using (host)
        {
            var response = await client.GetAsync("/api/centralui/audit/export?channel=DefinitelyNotAChannel");
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            _ = await response.Content.ReadAsStringAsync();

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f => f.Channels == null),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task ExportEndpoint_UnparseableExecutionId_SilentlyDropped()
    {
        // Lax-parse contract: an unparseable executionId is dropped (no 400) —
        // mirrors the correlationId parse.
        var (client, repo, host) = await BuildHostAsync();
        using (host)
        {
            var response = await client.GetAsync("/api/centralui/audit/export?executionId=not-a-guid");
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            _ = await response.Content.ReadAsStringAsync();

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f => f.ExecutionId == null),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    [Fact]
    public async Task ExportEndpoint_UnparseableParentExecutionId_SilentlyDropped()
    {
        // Lax-parse contract: an unparseable parentExecutionId is dropped (no 400)
        // — mirrors the executionId / correlationId parse.
        var (client, repo, host) = await BuildHostAsync();
        using (host)
        {
            var response = await client.GetAsync("/api/centralui/audit/export?parentExecutionId=not-a-guid");
            Assert.Equal(HttpStatusCode.OK, response.StatusCode);
            _ = await response.Content.ReadAsStringAsync();

            await repo.Received().QueryAsync(
                Arg.Is<AuditLogQueryFilter>(f => f.ParentExecutionId == null),
                Arg.Any<AuditLogPaging>(),
                Arg.Any<CancellationToken>());
        }
    }

    /// <summary>
    /// Test-only authentication handler that signs every request in as an Admin.
    /// Admin is in <c>AuditExportRoles</c>, so the endpoint's AuditExport policy
    /// passes without spinning up the real cookie + LDAP pipeline.
    /// </summary>
    private sealed class FakeAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        public const string SchemeName = "FakeAuth";

        public FakeAuthHandler(
            IOptionsMonitor<AuthenticationSchemeOptions> options,
            ILoggerFactory logger,
            UrlEncoder encoder)
            : base(options, logger, encoder) { }

        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            var claims = new[]
            {
                new Claim(ClaimTypes.Name, "test-admin"),
                new Claim(JwtTokenService.RoleClaimType, "Administrator"),
            };
            var identity = new ClaimsIdentity(claims, SchemeName);
            var principal = new ClaimsPrincipal(identity);
            var ticket = new AuthenticationTicket(principal, SchemeName);
            return Task.FromResult(AuthenticateResult.Success(ticket));
        }
    }

    [Fact]
    public void ExportEndpoint_RouteIsRegistered()
    {
        var builder = WebApplication.CreateBuilder();
        builder.Services.AddRouting();
        builder.Services.AddAuthorization();
        builder.Services.AddSingleton(Substitute.For<IAuditLogRepository>());
        builder.Services.AddScoped<IAuditLogExportService, AuditLogExportService>();
        // Dispose the host: an undisposed WebApplication leaks its config
        // PhysicalFileProvider watcher and the ConsoleLoggerProcessor thread.
        using var app = builder.Build();
        app.MapAuditExportEndpoints();

        var endpoints = ((IEndpointRouteBuilder)app).DataSources
            .SelectMany(ds => ds.Endpoints)
            .OfType<RouteEndpoint>()
            .ToList();

        var export = endpoints.FirstOrDefault(e =>
            e.RoutePattern.RawText == "/api/centralui/audit/export" &&
            (e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") ?? false));

        Assert.NotNull(export);
    }
}
