using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.AuditLog.Tests.TestSupport;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Tests.Central;

/// <summary>
/// M4 Bundle B1 — unit tests for <see cref="CentralAuditWriter"/>, the
/// central-only direct-write implementation of <see cref="ICentralAuditWriter"/>.
/// The writer is a thin wrapper around
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/>: it stamps
/// <see cref="AuditEvent.IngestedAtUtc"/>, resolves the (scoped) repository
/// from a fresh DI scope per call, and swallows any thrown exception —
/// audit-write failures NEVER abort the user-facing action (alog.md §13).
/// </summary>
public class CentralAuditWriterTests
{
    // C3 (Task 2.5): canonical ZB.MOM.WW.Audit.AuditEvent via the shared factory.
    private static AuditEvent NewEvent(Guid? eventId = null) =>
        ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.Notification,
            kind: AuditKind.NotifyDeliver,
            status: AuditStatus.Attempted,
            eventId: eventId ?? Guid.NewGuid(),
            occurredAtUtc: new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc),
            target: "ops-team",
            correlationId: Guid.NewGuid());

    private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriter()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var services = new ServiceCollection();
        services.AddScoped(_ => repo);
        var provider = services.BuildServiceProvider();
        return (new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance), repo);
    }

    [Fact]
    public async Task WriteAsync_PassesEvent_To_InsertIfNotExistsAsync()
    {
        var (writer, repo) = BuildWriter();
        var evt = NewEvent();

        await writer.WriteAsync(evt);

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.EventId == evt.EventId),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_Stamps_IngestedAtUtc_Before_Insert()
    {
        var (writer, repo) = BuildWriter();
        var before = DateTime.UtcNow;

        await writer.WriteAsync(NewEvent());

        var after = DateTime.UtcNow;
        await repo.Received(1).InsertIfNotExistsAsync(
            // C3 (Task 2.5): IngestedAtUtc now rides in DetailsJson on the canonical
            // record — read it back via the decomposed row view.
            Arg.Is<AuditEvent>(e =>
                e.AsRow().IngestedAtUtc != null &&
                e.AsRow().IngestedAtUtc >= before &&
                e.AsRow().IngestedAtUtc <= after),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_Repository_Throws_DoesNotPropagate()
    {
        var repo = Substitute.For<IAuditLogRepository>();
        repo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException("db down"));
        var services = new ServiceCollection();
        services.AddScoped(_ => repo);
        var provider = services.BuildServiceProvider();
        var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);

        // Must not throw — audit failure NEVER aborts the user-facing action.
        await writer.WriteAsync(NewEvent());
    }

    [Fact]
    public async Task WriteAsync_Resolves_Repository_PerCall_From_Fresh_Scope()
    {
        // Counting factory: every scope opening should resolve a new repo
        // (scoped lifetime). We assert at least two distinct instances
        // across two WriteAsync calls.
        var instances = new List<IAuditLogRepository>();
        var services = new ServiceCollection();
        services.AddScoped<IAuditLogRepository>(_ =>
        {
            var r = Substitute.For<IAuditLogRepository>();
            instances.Add(r);
            return r;
        });
        var provider = services.BuildServiceProvider();
        var writer = new CentralAuditWriter(provider, NullLogger<CentralAuditWriter>.Instance);

        await writer.WriteAsync(NewEvent());
        await writer.WriteAsync(NewEvent());

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

    [Fact]
    public void Constructor_NullServices_Throws()
    {
        Assert.Throws<ArgumentNullException>(
            () => new CentralAuditWriter(null!, NullLogger<CentralAuditWriter>.Instance));
    }

    [Fact]
    public void Constructor_NullLogger_Throws()
    {
        var services = new ServiceCollection().BuildServiceProvider();
        Assert.Throws<ArgumentNullException>(
            () => new CentralAuditWriter(services, null!));
    }

    // ----- SourceNode stamping (Task 12) ----- //

    private static (CentralAuditWriter writer, IAuditLogRepository repo) BuildWriterWithIdentity(
        INodeIdentityProvider? nodeIdentity)
    {
        var repo = Substitute.For<IAuditLogRepository>();
        var services = new ServiceCollection();
        services.AddScoped(_ => repo);
        var provider = services.BuildServiceProvider();
        var writer = new CentralAuditWriter(
            provider,
            NullLogger<CentralAuditWriter>.Instance,
            redactor: null,
            failureCounter: null,
            nodeIdentity: nodeIdentity);
        return (writer, repo);
    }

    [Fact]
    public async Task WriteAsync_StampsSourceNodeFromProvider_WhenEventHasNone()
    {
        var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));

        await writer.WriteAsync(NewEvent());

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.SourceNode == "central-a"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_PreservesCallerProvidedSourceNode()
    {
        var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider("central-a"));
        var evt = NewEvent() with { SourceNode = "central-b" };

        await writer.WriteAsync(evt);

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.SourceNode == "central-b"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_LeavesSourceNodeNull_WhenProviderReturnsNull()
    {
        var (writer, repo) = BuildWriterWithIdentity(new FakeNodeIdentityProvider(nodeName: null));

        await writer.WriteAsync(NewEvent());

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.SourceNode == null),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_PassesThroughCallerSourceNode_WhenNoProviderInjected()
    {
        // Locks the back-compat contract for the optional `nodeIdentity = null`
        // ctor parameter: when no provider is wired (e.g. legacy M4 test
        // composition roots), the writer must not stamp — caller value passes
        // through unmodified. Distinct code path from
        // "provider supplied, returns null", which the test above covers.
        var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);

        await writer.WriteAsync(NewEvent() with { SourceNode = "node-z" });

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.SourceNode == "node-z"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task WriteAsync_LeavesSourceNodeNull_WhenNoProviderInjected()
    {
        // Same back-compat contract for the null-caller-null-provider case.
        var (writer, repo) = BuildWriterWithIdentity(nodeIdentity: null);

        await writer.WriteAsync(NewEvent());

        await repo.Received(1).InsertIfNotExistsAsync(
            Arg.Is<AuditEvent>(e => e.SourceNode == null),
            Arg.Any<CancellationToken>());
    }
}
