using FluentAssertions;
using Microsoft.Extensions.Options;
using NSubstitute;
using SIGCM2.Application.Audit;
using SIGCM2.Infrastructure.Audit;
using Xunit;

namespace SIGCM2.Application.Tests.Infrastructure.Audit;

/// UDT-010 Batch 6 — SecurityEventLogger unit tests.
/// NOT fail-closed: security events are fire-and-forget writes; actor may be null
/// for login failures.
public sealed class SecurityEventLoggerTests
{
    private static SecurityEventLogger Build(
        ISecurityEventRepository? repo = null,
        IAuditContext? context = null,
        AuditOptions? options = null)
    {
        repo ??= Substitute.For<ISecurityEventRepository>();
        context ??= Substitute.For<IAuditContext>();
        options ??= new AuditOptions();
        return new SecurityEventLogger(repo, context, Options.Create(options), TimeProvider.System);
    }

    [Fact]
    public async Task LogAsync_LoginSuccess_PassesActorAndIpFromContext()
    {
        var repo = Substitute.For<ISecurityEventRepository>();
        var context = Substitute.For<IAuditContext>();
        context.Ip.Returns("1.2.3.4");
        context.UserAgent.Returns("ua");
        var logger = Build(repo, context);

        await logger.LogAsync("login", "success", actorUserId: 42);

        await repo.Received(1).InsertAsync(
            Arg.Any<DateTime>(),
            42,
            Arg.Is<string?>(s => s == null),      // attemptedUsername
            Arg.Is<Guid?>(g => g == null),        // sessionId
            "login", "success",
            Arg.Is<string?>(s => s == null),      // failureReason
            "1.2.3.4",
            "ua",
            Arg.Is<string?>(s => s == null),      // metadata
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task LogAsync_LoginFailure_SupportsNullActorAndAttemptedUsername()
    {
        var repo = Substitute.For<ISecurityEventRepository>();
        var logger = Build(repo);

        await logger.LogAsync("login", "failure",
            actorUserId: null,
            attemptedUsername: "juan",
            failureReason: "invalid_password");

        await repo.Received(1).InsertAsync(
            Arg.Any<DateTime>(),
            Arg.Is<int?>(i => i == null),
            "juan",
            Arg.Is<Guid?>(g => g == null),
            "login", "failure",
            "invalid_password",
            Arg.Any<string?>(), Arg.Any<string?>(),
            Arg.Is<string?>(s => s == null),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task LogAsync_SanitizesMetadata()
    {
        var repo = Substitute.For<ISecurityEventRepository>();
        var logger = Build(repo);

        await logger.LogAsync("login", "failure", attemptedUsername: "x",
            metadata: new { token = "leaked", ip = "1.1.1.1" });

        await repo.Received(1).InsertAsync(
            Arg.Any<DateTime>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<Guid?>(),
            Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
            Arg.Any<string?>(), Arg.Any<string?>(),
            Arg.Is<string?>(m => m != null && !m.Contains("\"token\"") && m.Contains("\"ip\"")),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task LogAsync_DoesNotThrow_WhenActorAndAttemptedUsernameAreBothNull()
    {
        // This is a legitimate use case (e.g. permission.denied emitted by middleware with an expired token).
        var repo = Substitute.For<ISecurityEventRepository>();
        var logger = Build(repo);

        var act = async () => await logger.LogAsync("permission.denied", "failure");

        await act.Should().NotThrowAsync();
    }
}
