using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using Xunit;

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

/// <summary>
/// Bundle D (#23 M6-T5) tests for <see cref="AuditLogPartitionMaintenanceService"/>.
/// All tests use an in-memory <see cref="IPartitionMaintenance"/> stub —
/// the real EF/MSSQL implementation is exercised by the
/// <c>AuditLogPartitionMaintenanceTests</c> integration suite in
/// <c>ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests</c>. This file is purely
/// about the hosted service's policy decisions (start/stop, exception
/// containment).
/// </summary>
public class AuditLogPartitionMaintenanceServiceTests
{
    /// <summary>
    /// Recording stub — counts EnsureLookaheadAsync invocations and lets the
    /// test inject an exception per invocation to drive the catch-all path.
    /// </summary>
    private sealed class RecordingMaintenance : IPartitionMaintenance
    {
        public int EnsureCallCount;
        public Exception? ThrowOnce;

        public Task<IReadOnlyList<DateTime>> EnsureLookaheadAsync(int lookaheadMonths, CancellationToken ct = default)
        {
            Interlocked.Increment(ref EnsureCallCount);
            if (ThrowOnce is { } ex)
            {
                ThrowOnce = null;
                throw ex;
            }
            return Task.FromResult<IReadOnlyList<DateTime>>(Array.Empty<DateTime>());
        }

        public Task<DateTime?> GetMaxBoundaryAsync(CancellationToken ct = default) =>
            Task.FromResult<DateTime?>(DateTime.UtcNow.AddMonths(6));
    }

    /// <summary>
    /// Captures logged exceptions so the catch-all assertion can prove
    /// the exception was actually logged (not silently swallowed) and was
    /// the exact instance the stub threw.
    /// </summary>
    private sealed class CapturingLogger : ILogger<AuditLogPartitionMaintenanceService>
    {
        public List<(LogLevel Level, Exception? Exception, string Message)> Entries { get; } = new();

        public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel,
            EventId eventId,
            TState state,
            Exception? exception,
            Func<TState, Exception?, string> formatter)
        {
            Entries.Add((logLevel, exception, formatter(state, exception)));
        }
    }

    private static IServiceProvider BuildProvider(IPartitionMaintenance maintenance)
    {
        var services = new ServiceCollection();
        // IPartitionMaintenance is registered as scoped by AddConfigurationDatabase;
        // we mirror that here so the hosted service's CreateAsyncScope +
        // GetRequiredService resolves the stub the test injected.
        services.AddScoped(_ => maintenance);
        return services.BuildServiceProvider();
    }

    [Fact]
    public async Task StartStop_NoExceptions()
    {
        // Long interval so only the eager startup tick fires inside the test
        // window — keeps assertions deterministic without relying on
        // multiple cadence loops.
        var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
        {
            IntervalSeconds = 60,
            LookaheadMonths = 1,
        });
        var maintenance = new RecordingMaintenance();
        var sp = BuildProvider(maintenance);

        var svc = new AuditLogPartitionMaintenanceService(
            sp.GetRequiredService<IServiceScopeFactory>(),
            opts,
            NullLogger<AuditLogPartitionMaintenanceService>.Instance);

        await svc.StartAsync(CancellationToken.None);

        // Spin briefly until the startup tick has fired — the loop's first
        // SafeMaintainAsync runs on a background Task.Run continuation, so
        // we can't synchronously rely on its completion.
        var deadline = DateTime.UtcNow.AddSeconds(3);
        while (Volatile.Read(ref maintenance.EnsureCallCount) < 1 && DateTime.UtcNow < deadline)
        {
            await Task.Delay(20);
        }

        await svc.StopAsync(CancellationToken.None);
        svc.Dispose();

        Assert.True(maintenance.EnsureCallCount >= 1, $"expected at least 1 ensure call, got {maintenance.EnsureCallCount}");
    }

    [Fact]
    public async Task SafeMaintain_ExceptionLogged_NotPropagated()
    {
        var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
        {
            IntervalSeconds = 60,
            LookaheadMonths = 1,
        });
        // The injected exception fires on the FIRST EnsureLookaheadAsync call
        // (the startup tick) — the hosted service must contain it and
        // continue running.
        var boom = new InvalidOperationException("simulated maintenance failure");
        var maintenance = new RecordingMaintenance { ThrowOnce = boom };
        var sp = BuildProvider(maintenance);
        var logger = new CapturingLogger();

        var svc = new AuditLogPartitionMaintenanceService(
            sp.GetRequiredService<IServiceScopeFactory>(),
            opts,
            logger);

        // StartAsync must not throw even though the very first tick will fail.
        await svc.StartAsync(CancellationToken.None);

        // Wait for the error to surface in the logger.
        var deadline = DateTime.UtcNow.AddSeconds(3);
        while (!logger.Entries.Any(e => e.Exception == boom) && DateTime.UtcNow < deadline)
        {
            await Task.Delay(20);
        }

        await svc.StopAsync(CancellationToken.None);
        svc.Dispose();

        var errorEntry = Assert.Single(logger.Entries, e => e.Exception == boom);
        Assert.Equal(LogLevel.Error, errorEntry.Level);
        Assert.Equal(1, maintenance.EnsureCallCount);
    }

    [Fact]
    public async Task StopAsync_AfterDispose_DoesNotThrow()
    {
        // Regression (Gitea #15 follow-up): Dispose tears down the CTS that
        // StopAsync cancels, and the host does not guarantee that every
        // IHostedService.StopAsync is driven before the DI container is
        // disposed — WebApplicationFactory's teardown reaches Dispose first and
        // then calls StopAsync. Cancel() on a disposed CTS throws
        // ObjectDisposedException, and letting it escape aborts the host's whole
        // shutdown sequence (in Host.Tests it stranded the fixture's env-var
        // restore, contaminating every later test in the run). Stop-after-Dispose
        // must therefore be a no-op, not a throw.
        var opts = Options.Create(new AuditLogPartitionMaintenanceOptions
        {
            IntervalSeconds = 60,
            LookaheadMonths = 1,
        });
        var maintenance = new RecordingMaintenance();
        var sp = BuildProvider(maintenance);

        var svc = new AuditLogPartitionMaintenanceService(
            sp.GetRequiredService<IServiceScopeFactory>(),
            opts,
            NullLogger<AuditLogPartitionMaintenanceService>.Instance);

        await svc.StartAsync(CancellationToken.None);

        svc.Dispose();

        // The assertion is the absence of ObjectDisposedException.
        await svc.StopAsync(CancellationToken.None);

        // Dispose is also idempotent — the host may reach it twice on the same path.
        svc.Dispose();
    }
}
