using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Audit;
using ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Harness;

namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests;

public sealed class AuditWriterActorTests : ControlPlaneActorTestBase
{
    private static AuditEvent NewEvent(Guid eventId, string action = "Edit", string actor = "joe") =>
        new()
        {
            EventId = eventId,
            Category = "Config",
            Action = action,
            Actor = actor,
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Outcome = AuditOutcomeMapper.FromAction(action),
            DetailsJson = "{\"field\":\"value\"}",
            SourceNode = "node-a",
            CorrelationId = Guid.NewGuid(),
        };

    /// <summary>Verifies that buffered events flush when count threshold is reached.</summary>
    [Fact]
    public void Buffered_events_flush_on_count_threshold()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        // Sending exactly FlushBatchSize events triggers a flush.
        for (var i = 0; i < AuditWriterActor.FlushBatchSize; i++)
            actor.Tell(NewEvent(Guid.NewGuid()));

        // Give the actor a beat to process the messages.
        AwaitAssert(() =>
        {
            using var db = dbFactory.CreateDbContext();
            db.ConfigAuditLogs.Count().ShouldBe(AuditWriterActor.FlushBatchSize);
        }, duration: TimeSpan.FromSeconds(2));
    }

    /// <summary>Verifies that duplicate event IDs within a batch are deduplicated in the buffer.</summary>
    [Fact]
    public void Duplicate_eventIds_within_a_batch_dedup_in_buffer()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        // Send 1000 messages, but only 100 unique EventIds (10x duplication).
        var uniqueIds = Enumerable.Range(0, 100).Select(_ => Guid.NewGuid()).ToArray();
        for (var i = 0; i < 1000; i++)
            actor.Tell(NewEvent(uniqueIds[i % 100]));

        // Force a flush — send PoisonPill, which triggers PostStop → FlushBuffer.
        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        db.ConfigAuditLogs.Count().ShouldBe(100, "in-buffer dedup should collapse duplicate EventIds");
    }

    /// <summary>Verifies that PostStop flushes the pending buffer.</summary>
    [Fact]
    public void PostStop_flushes_pending_buffer()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        // 10 events — well below the threshold, so they sit in-buffer.
        for (var i = 0; i < 10; i++)
            actor.Tell(NewEvent(Guid.NewGuid()));

        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        db.ConfigAuditLogs.Count().ShouldBe(10);
    }

    /// <summary>Verifies that EventId and CorrelationId are persisted to dedicated columns.</summary>
    [Fact]
    public void EventId_and_CorrelationId_are_persisted_to_dedicated_columns()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        var eventId = Guid.NewGuid();
        actor.Tell(NewEvent(eventId));

        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        var row = db.ConfigAuditLogs.Single();
        row.EventId.ShouldBe(eventId);
        row.CorrelationId.ShouldNotBeNull();
        row.DetailsJson.ShouldBe("{\"field\":\"value\"}");
        row.EventType.ShouldBe("Config:Edit");
        row.NodeId.ShouldBe("node-a");
    }

    /// <summary>Verifies that a null SourceNode/CorrelationId on the canonical event persists as null
    /// (the canonical fields are now nullable; the actor must not assume they are set).</summary>
    [Fact]
    public void Null_sourceNode_and_correlationId_persist_as_null()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        actor.Tell(new AuditEvent
        {
            EventId = Guid.NewGuid(),
            Category = "Config",
            Action = "Published",
            Actor = "joe",
            OccurredAtUtc = DateTimeOffset.UtcNow,
            Outcome = AuditOutcome.Success,
            SourceNode = null,
            CorrelationId = null,
        });

        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        var row = db.ConfigAuditLogs.Single();
        row.NodeId.ShouldBeNull();
        row.CorrelationId.ShouldBeNull();
    }

    /// <summary>Verifies the IAuditWriter.WriteAsync seam is best-effort: it completes
    /// synchronously, never throws, and routes the event onto the actor's own mailbox
    /// (<c>Self.Tell</c>) — i.e. the same buffer + dedup + flush pipeline asserted by the Tell
    /// tests above. Reaches the concrete instance via a TestActorRef.</summary>
    [Fact]
    public async Task WriteAsync_is_best_effort_and_routes_onto_the_actor_mailbox()
    {
        var dbFactory = NewInMemoryDbFactory();
        var testRef = ActorOfAsTestActorRef<AuditWriterActor>(AuditWriterActor.Props(dbFactory));
        IAuditWriter writer = testRef.UnderlyingActor;

        var task = writer.WriteAsync(NewEvent(Guid.NewGuid(), action: "Published"));
        task.IsCompletedSuccessfully.ShouldBeTrue("WriteAsync must be best-effort and complete synchronously");
        await Should.NotThrowAsync(async () => await task);
    }

    /// <summary>Verifies that an AuditEvent delivered to the actor's mailbox — which is exactly
    /// what the WriteAsync seam does via Self.Tell — is buffered and persisted with the canonical
    /// fields intact.</summary>
    [Fact]
    public void Mailbox_delivery_persists_the_canonical_fields()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        var eventId = Guid.NewGuid();
        actor.Tell(NewEvent(eventId, action: "Published"));

        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        var row = db.ConfigAuditLogs.Single();
        row.EventId.ShouldBe(eventId);
        row.EventType.ShouldBe("Config:Published");
        row.NodeId.ShouldBe("node-a");
        // The derived canonical Outcome is persisted as its enum member name (Task 2.2 column).
        row.Outcome.ShouldBe(nameof(AuditOutcome.Success));
    }

    /// <summary>Verifies that a Denied-outcome event persists "Denied" to the Outcome column.</summary>
    [Fact]
    public void Denied_outcome_is_persisted_as_its_enum_member_name()
    {
        var dbFactory = NewInMemoryDbFactory();
        var actor = Sys.ActorOf(AuditWriterActor.Props(dbFactory));

        actor.Tell(NewEvent(Guid.NewGuid(), action: "OpcUaAccessDenied"));

        Watch(actor);
        actor.Tell(PoisonPill.Instance);
        ExpectTerminated(actor);

        using var db = dbFactory.CreateDbContext();
        var row = db.ConfigAuditLogs.Single();
        row.Outcome.ShouldBe(nameof(AuditOutcome.Denied));
        row.EventType.ShouldBe("Config:OpcUaAccessDenied");
    }

    /// <summary>Verifies the Outcome derivation table: config verbs → Success, the two
    /// authorization-rejection events → Denied.</summary>
    [Theory]
    [InlineData("DraftCreated", AuditOutcome.Success)]
    [InlineData("DraftEdited", AuditOutcome.Success)]
    [InlineData("Published", AuditOutcome.Success)]
    [InlineData("RolledBack", AuditOutcome.Success)]
    [InlineData("NodeApplied", AuditOutcome.Success)]
    [InlineData("ClusterCreated", AuditOutcome.Success)]
    [InlineData("NodeAdded", AuditOutcome.Success)]
    [InlineData("CredentialAdded", AuditOutcome.Success)]
    [InlineData("CredentialDisabled", AuditOutcome.Success)]
    [InlineData("ExternalIdReleased", AuditOutcome.Success)]
    [InlineData("OpcUaAccessDenied", AuditOutcome.Denied)]
    [InlineData("CrossClusterNamespaceAttempt", AuditOutcome.Denied)]
    public void Outcome_is_derived_from_the_action_vocabulary(string action, AuditOutcome expected) =>
        AuditOutcomeMapper.FromAction(action).ShouldBe(expected);
}
