using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Integration;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;

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

/// <summary>
/// Purge-scheduler tests for <see cref="SiteCallAuditActor"/> (#22, Piece B).
/// Exercises the daily terminal-row purge tick in-memory — a recording
/// <see cref="ISiteCallAuditRepository"/> captures the
/// <see cref="ISiteCallAuditRepository.PurgeTerminalAsync"/> threshold the actor
/// computes, with no live MSSQL fixture. The reconciliation collaborators are
/// inert stubs (the purge tick doesn't use them, but they must be present to
/// arm the scheduler — both timers gate on the collaborators together).
/// </summary>
public class SiteCallAuditPurgeTests : TestKit
{
    private static SiteCallAuditOptions FastPurgeOptions(int retentionDays = 365) => new()
    {
        // Keep the reconciliation tick slow so it doesn't fight the purge tick
        // for the test window; drop the purge tick to 100 ms via its override.
        ReconciliationIntervalOverride = TimeSpan.FromMinutes(5),
        PurgeIntervalOverride = TimeSpan.FromMilliseconds(100),
        RetentionDays = retentionDays,
    };

    /// <summary>Empty enumerator — the purge path never touches it, but it must be present to arm the scheduler.</summary>
    private sealed class EmptyEnumerator : ISiteEnumerator
    {
        public Task<IReadOnlyList<SiteEntry>> EnumerateAsync(CancellationToken ct = default) =>
            Task.FromResult<IReadOnlyList<SiteEntry>>(Array.Empty<SiteEntry>());
    }

    /// <summary>No-op pull client — present only to arm the scheduler.</summary>
    private sealed class NoOpPullClient : IPullSiteCallsClient
    {
        public Task<PullSiteCallsResponse> PullAsync(
            string siteId, DateTime sinceUtc, string? afterId, int batchSize, CancellationToken ct) =>
            Task.FromResult(new PullSiteCallsResponse(Array.Empty<SiteCall>(), MoreAvailable: false));
    }

    /// <summary>
    /// Recording repository capturing every <see cref="PurgeTerminalAsync"/>
    /// threshold (and the configured deleted-row count it returns).
    /// </summary>
    private sealed class RecordingRepo : ISiteCallAuditRepository
    {
        public List<DateTime> PurgeThresholds { get; } = new();
        public int RowsDeletedPerCall { get; set; }

        public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
        {
            PurgeThresholds.Add(olderThanUtc);
            return Task.FromResult(RowsDeletedPerCall);
        }

        public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask;

        public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) =>
            Task.FromResult<SiteCall?>(null);

        public Task<IReadOnlyList<SiteCall>> QueryAsync(
            SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default) =>
            Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());

        public Task<SiteCallKpiSnapshot> ComputeKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
            Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));

        public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
            Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());

        public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(
            DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default) =>
            Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
    }

    /// <summary>Repository whose purge always throws — to prove continue-on-error keeps the singleton alive.</summary>
    private sealed class PurgeThrowingRepo : ISiteCallAuditRepository
    {
        public int PurgeCallCount;

        public Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
        {
            Interlocked.Increment(ref PurgeCallCount);
            throw new InvalidOperationException("simulated purge failure");
        }

        public Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default) => Task.CompletedTask;
        public Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default) => Task.FromResult<SiteCall?>(null);
        public Task<IReadOnlyList<SiteCall>> QueryAsync(SiteCallQueryFilter f, SiteCallPaging p, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCall>>(Array.Empty<SiteCall>());
        public Task<SiteCallKpiSnapshot> ComputeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult(new SiteCallKpiSnapshot(0, 0, 0, 0, null, 0));
        public Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallSiteKpiSnapshot>>(Array.Empty<SiteCallSiteKpiSnapshot>());
        public Task<IReadOnlyList<SiteCallNodeKpiSnapshot>> ComputePerNodeKpisAsync(DateTime a, DateTime b, CancellationToken ct = default) => Task.FromResult<IReadOnlyList<SiteCallNodeKpiSnapshot>>(Array.Empty<SiteCallNodeKpiSnapshot>());
    }

    private IActorRef CreateActor(ISiteCallAuditRepository repo, SiteCallAuditOptions options) =>
        Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
            repo,
            new EmptyEnumerator(),
            new NoOpPullClient(),
            NullLogger<SiteCallAuditActor>.Instance,
            options)));

    // ---------------------------------------------------------------------
    // 1. PurgeTick_CallsPurgeTerminal_WithRetentionThreshold
    // ---------------------------------------------------------------------

    [Fact]
    public void PurgeTick_CallsPurgeTerminalAsync_WithRetentionThreshold()
    {
        var repo = new RecordingRepo { RowsDeletedPerCall = 7 };
        // Non-default retention (30 days) so the assertion isn't accidentally
        // satisfied by the 365-day default.
        CreateActor(repo, FastPurgeOptions(retentionDays: 30));

        AwaitAssert(
            () => Assert.True(repo.PurgeThresholds.Count >= 1,
                $"expected >= 1 PurgeTerminalAsync call, got {repo.PurgeThresholds.Count}"),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        // The threshold the actor passed must be ~UtcNow - 30 days. 1-minute
        // slack covers scheduling jitter between the tick firing and the assert.
        var threshold = repo.PurgeThresholds[0];
        var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
        Assert.True(
            Math.Abs((threshold - expected).TotalMinutes) < 1.0,
            $"purge threshold {threshold:o} should be within 1 minute of {expected:o}");
    }

    // ---------------------------------------------------------------------
    // 2. PurgeTick_UsesDefaultRetention_365Days
    // ---------------------------------------------------------------------

    [Fact]
    public void PurgeTick_DefaultRetention_Uses365DayThreshold()
    {
        var repo = new RecordingRepo();
        CreateActor(repo, FastPurgeOptions()); // default 365 days

        AwaitAssert(
            () => Assert.True(repo.PurgeThresholds.Count >= 1),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        var threshold = repo.PurgeThresholds[0];
        var expected = DateTime.UtcNow - TimeSpan.FromDays(365);
        Assert.True(
            Math.Abs((threshold - expected).TotalMinutes) < 1.0,
            $"purge threshold {threshold:o} should be within 1 minute of {expected:o}");
    }

    // ---------------------------------------------------------------------
    // 3. PurgeTick_RepoThrows_ActorStaysAlive_RetriesNextTick (continue-on-error)
    // ---------------------------------------------------------------------

    [Fact]
    public void PurgeTick_PurgeThrows_ActorStaysAlive_RetriesNextTick()
    {
        var repo = new PurgeThrowingRepo();
        CreateActor(repo, FastPurgeOptions());

        // The singleton must NOT die on a purge fault — a second tick must still
        // arrive (continue-on-error). Two purge calls prove the actor survived
        // the first throw and the timer kept ticking.
        AwaitAssert(
            () => Assert.True(repo.PurgeCallCount >= 2,
                $"expected >= 2 purge attempts (actor survived the throw), got {repo.PurgeCallCount}"),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));
    }

    // ---------------------------------------------------------------------
    // 4. SiteCallAudit-007: purge timer arms even when the reconciliation
    //    collaborators are ABSENT (production ctor, no IPullSiteCallsClient /
    //    ISiteEnumerator registered). Proves the decoupling — a host that omits
    //    the reconciliation client still purges, so the central SiteCalls table
    //    cannot grow unbounded.
    // ---------------------------------------------------------------------

    [Fact]
    public void PurgeTick_ProductionCtor_NoReconciliationCollaborators_StillPurges()
    {
        var repo = new RecordingRepo { RowsDeletedPerCall = 3 };

        // Build a DI container that registers the repository the production
        // ctor resolves per-tick, but deliberately registers NEITHER
        // IPullSiteCallsClient NOR ISiteEnumerator. GetService returns null for
        // both, so the actor's reconciliation tick is gated off — but the purge
        // tick must still arm (SiteCallAudit-007).
        var provider = new ServiceCollection()
            .AddScoped<ISiteCallAuditRepository>(_ => repo)
            .BuildServiceProvider();

        var options = FastPurgeOptions(retentionDays: 30);
        Sys.ActorOf(Props.Create(() => new SiteCallAuditActor(
            provider,
            options,
            NullLogger<SiteCallAuditActor>.Instance)));

        // No reconciliation collaborators were registered, yet the purge tick
        // must still fire on the production path.
        AwaitAssert(
            () => Assert.True(repo.PurgeThresholds.Count >= 1,
                "purge timer must arm even when the reconciliation collaborators are absent "
                + $"(SiteCallAudit-007), got {repo.PurgeThresholds.Count} purge calls"),
            duration: TimeSpan.FromSeconds(3),
            interval: TimeSpan.FromMilliseconds(50));

        var threshold = repo.PurgeThresholds[0];
        var expected = DateTime.UtcNow - TimeSpan.FromDays(30);
        Assert.True(
            Math.Abs((threshold - expected).TotalMinutes) < 1.0,
            $"purge threshold {threshold:o} should be within 1 minute of {expected:o}");
    }
}
