using System.Net;
using System.Text.Json;
using MailKit;
using MailKit.Net.Smtp;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.Commons.Entities.Notifications;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.StoreAndForward;

namespace ScadaLink.NotificationService.Tests;

/// <summary>
/// WP-11/12: Tests for notification delivery — SMTP delivery, error classification, S&amp;F integration.
/// </summary>
public class NotificationDeliveryServiceTests
{
    private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
    private readonly ISmtpClientWrapper _smtpClient = Substitute.For<ISmtpClientWrapper>();

    private NotificationDeliveryService CreateService(StoreAndForward.StoreAndForwardService? sf = null)
    {
        return new NotificationDeliveryService(
            _repository,
            () => _smtpClient,
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: null,
            storeAndForward: sf);
    }

    private void SetupHappyPath()
    {
        var list = new NotificationList("ops-team") { Id = 1 };
        var recipients = new List<NotificationRecipient>
        {
            new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
            new("Bob", "bob@example.com") { Id = 2, NotificationListId = 1 }
        };
        var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
        };

        _repository.GetListByNameAsync("ops-team").Returns(list);
        _repository.GetRecipientsByListIdAsync(1).Returns(recipients);
        _repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
    }

    [Fact]
    public async Task Send_ListNotFound_ReturnsError()
    {
        _repository.GetListByNameAsync("nonexistent").Returns((NotificationList?)null);
        var service = CreateService();

        var result = await service.SendAsync("nonexistent", "Subject", "Body");

        Assert.False(result.Success);
        Assert.Contains("not found", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_NoRecipients_ReturnsError()
    {
        var list = new NotificationList("empty-list") { Id = 1 };
        _repository.GetListByNameAsync("empty-list").Returns(list);
        _repository.GetRecipientsByListIdAsync(1).Returns(new List<NotificationRecipient>());

        var service = CreateService();
        var result = await service.SendAsync("empty-list", "Subject", "Body");

        Assert.False(result.Success);
        Assert.Contains("no recipients", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_NoSmtpConfig_ReturnsError()
    {
        var list = new NotificationList("test") { Id = 1 };
        var recipients = new List<NotificationRecipient>
        {
            new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
        };
        _repository.GetListByNameAsync("test").Returns(list);
        _repository.GetRecipientsByListIdAsync(1).Returns(recipients);
        _repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration>());

        var service = CreateService();
        var result = await service.SendAsync("test", "Subject", "Body");

        Assert.False(result.Success);
        Assert.Contains("No SMTP configuration", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_Successful_ReturnsSuccess()
    {
        SetupHappyPath();
        var service = CreateService();

        var result = await service.SendAsync("ops-team", "Alert", "Something happened");

        Assert.True(result.Success);
        Assert.Null(result.ErrorMessage);
        Assert.False(result.WasBuffered);
    }

    [Fact]
    public async Task Send_SmtpConnectsWithCorrectParams()
    {
        SetupHappyPath();
        var service = CreateService();

        await service.SendAsync("ops-team", "Alert", "Body");

        await _smtpClient.Received().ConnectAsync(
            "smtp.example.com", 587, SmtpTlsMode.StartTls, Arg.Any<int>(), Arg.Any<CancellationToken>());
        await _smtpClient.Received().AuthenticateAsync("basic", "user:pass", Arg.Any<CancellationToken>());
        await _smtpClient.Received().SendAsync(
            "noreply@example.com",
            Arg.Is<IEnumerable<string>>(bcc => bcc.Count() == 2),
            "Alert",
            "Body",
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task Send_PermanentSmtpError_ReturnsErrorDirectly()
    {
        SetupHappyPath();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new SmtpPermanentException("550 Mailbox not found"));

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("Permanent SMTP error", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_TransientError_NoStoreAndForward_ReturnsError()
    {
        SetupHappyPath();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new TimeoutException("Connection timed out"));

        var service = CreateService(sf: null);
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("store-and-forward not available", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_UsesBccDelivery_AllRecipientsInBcc()
    {
        SetupHappyPath();
        IEnumerable<string>? capturedBcc = null;
        _smtpClient.SendAsync(
            Arg.Any<string>(),
            Arg.Do<IEnumerable<string>>(bcc => capturedBcc = bcc),
            Arg.Any<string>(),
            Arg.Any<string>(),
            Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);

        var service = CreateService();
        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.NotNull(capturedBcc);
        var bccList = capturedBcc!.ToList();
        Assert.Equal(2, bccList.Count);
        Assert.Contains("alice@example.com", bccList);
        Assert.Contains("bob@example.com", bccList);
    }

    [Fact]
    public async Task Send_TransientError_WithStoreAndForward_BuffersMessage()
    {
        SetupHappyPath();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new TimeoutException("Connection timed out"));

        var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared";
        var storage = new StoreAndForward.StoreAndForwardStorage(
            $"Data Source={dbName}", NullLogger<StoreAndForward.StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();

        var sfOptions = new StoreAndForward.StoreAndForwardOptions();
        var sfService = new StoreAndForward.StoreAndForwardService(
            storage, sfOptions, NullLogger<StoreAndForward.StoreAndForwardService>.Instance);

        var service = CreateService(sf: sfService);
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.True(result.WasBuffered);
    }

    // ── NotificationService-001: buffered-notification delivery handler ──

    private static StoreAndForward.StoreAndForwardMessage BufferedNotification(string listName) =>
        new()
        {
            Id = Guid.NewGuid().ToString("N"),
            Category = ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification,
            Target = listName,
            PayloadJson = $$"""{"ListName":"{{listName}}","Subject":"Alert","Message":"Body"}""",
        };

    [Fact]
    public async Task DeliverBuffered_HappyPath_ReturnsTrue()
    {
        SetupHappyPath();
        var service = CreateService();

        var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));

        Assert.True(delivered);
    }

    [Fact]
    public async Task DeliverBuffered_ListNoLongerExists_ReturnsFalseSoMessageParks()
    {
        _repository.GetListByNameAsync("gone-list").Returns((NotificationList?)null);
        var service = CreateService();

        var delivered = await service.DeliverBufferedAsync(BufferedNotification("gone-list"));

        Assert.False(delivered); // permanent — the S&F engine parks the message
    }

    // ── NotificationService-002: cancellation must not be misclassified as transient ──

    /// <summary>
    /// Like <see cref="SetupHappyPath"/> but matches any <see cref="CancellationToken"/>,
    /// so tests that pass an already-cancelled token still resolve the list/recipients.
    /// </summary>
    private void SetupHappyPathAnyToken()
    {
        var list = new NotificationList("ops-team") { Id = 1 };
        var recipients = new List<NotificationRecipient>
        {
            new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
        };
        var smtpConfig = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
        };

        _repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
        _repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>()).Returns(recipients);
        _repository.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<SmtpConfiguration> { smtpConfig });
    }

    [Fact]
    public async Task Send_CancellationRequested_PropagatesAndDoesNotBuffer()
    {
        SetupHappyPathAnyToken();
        using var cts = new CancellationTokenSource();
        cts.Cancel();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new OperationCanceledException(cts.Token));

        var sfService = await CreateSfServiceAsync();
        var service = CreateService(sf: sfService);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token));

        // The cancellation propagated instead of being buffered for retry.
        var depth = await sfService.GetBufferDepthAsync();
        depth.TryGetValue(ScadaLink.Commons.Types.Enums.StoreAndForwardCategory.Notification, out var count);
        Assert.Equal(0, count);
    }

    [Fact]
    public async Task Send_TaskCanceledException_WithCancellation_Propagates()
    {
        SetupHappyPathAnyToken();
        using var cts = new CancellationTokenSource();
        cts.Cancel();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new TaskCanceledException());

        var service = CreateService(sf: null);

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => service.SendAsync("ops-team", "Alert", "Body", cancellationToken: cts.Token));
    }

    // ── NotificationService-003: classify on MailKit typed exceptions / status codes ──

    [Fact]
    public async Task Send_Smtp5xxCommandException_ClassifiedPermanent()
    {
        SetupHappyPath();
        // 550 MailboxUnavailable — a real permanent rejection.
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new SmtpCommandException(
                SmtpErrorCode.RecipientNotAccepted, SmtpStatusCode.MailboxUnavailable, "rejected"));

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("Permanent SMTP error", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_Smtp4xxCommandException_ClassifiedTransientAndBuffered()
    {
        SetupHappyPath();
        // 450 MailboxBusy — a real transient failure.
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new SmtpCommandException(
                SmtpErrorCode.MessageNotAccepted, SmtpStatusCode.MailboxBusy, "try again"));

        var sfService = await CreateSfServiceAsync();
        var service = CreateService(sf: sfService);
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.True(result.WasBuffered);
    }

    [Fact]
    public async Task Send_NonSmtpExceptionWith5xxLookalikeText_NotClassifiedAsPermanentSmtpError()
    {
        // NS-003: the old classifier promoted ANY exception whose message contained
        // "5." / "550" / etc. to a permanent SMTP error — so an unrelated failure
        // referencing a host like "smtp5.example.com" was silently swallowed as a
        // clean "Permanent SMTP error" result. Classification now uses MailKit's
        // typed exceptions only, so a non-SMTP exception is no longer misclassified.
        // NS-015: that unclassified exception no longer escapes SendAsync either — it
        // returns a clean generic "delivery failed" result (NOT "Permanent SMTP error").
        SetupHappyPath();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new InvalidOperationException("internal error talking to smtp5.example.com"));

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        // It is reported as a generic delivery failure, not mistaken for a 5xx rejection.
        Assert.DoesNotContain("Permanent SMTP error", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_SmtpProtocolException_ClassifiedTransient()
    {
        SetupHappyPath();
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new SmtpProtocolException("protocol error"));

        var sfService = await CreateSfServiceAsync();
        var service = CreateService(sf: sfService);
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.True(result.WasBuffered);
    }

    // ── NotificationService-004: DeliverAsync must create exactly one client and dispose it ──

    private sealed class TrackingSmtpClient : ISmtpClientWrapper, IDisposable
    {
        public bool Disposed { get; private set; }
        public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task DisconnectAsync(CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public void Dispose() => Disposed = true;
    }

    [Fact]
    public async Task Send_CreatesExactlyOneSmtpClient_AndDisposesIt()
    {
        SetupHappyPath();
        var created = new List<TrackingSmtpClient>();
        var service = new NotificationDeliveryService(
            _repository,
            () =>
            {
                var c = new TrackingSmtpClient();
                created.Add(c);
                return c;
            },
            NullLogger<NotificationDeliveryService>.Instance);

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.Single(created);                 // NS-004: factory invoked once, not twice
        Assert.True(created[0].Disposed);       // the client actually used is disposed
    }

    private static async Task<StoreAndForwardService> CreateSfServiceAsync()
    {
        var dbName = $"file:sf_test_{Guid.NewGuid():N}?mode=memory&cache=shared";
        var storage = new StoreAndForwardStorage(
            $"Data Source={dbName}", NullLogger<StoreAndForwardStorage>.Instance);
        await storage.InitializeAsync();
        return new StoreAndForwardService(
            storage, new StoreAndForwardOptions(), NullLogger<StoreAndForwardService>.Instance);
    }

    // ── NotificationService-010: SMTP client is disconnected on the failure path ──

    /// <summary>
    /// An SMTP wrapper that records whether <see cref="DisconnectAsync"/> ran and
    /// can be told to fail at a chosen stage of the delivery sequence.
    /// </summary>
    private sealed class DisconnectTrackingClient : ISmtpClientWrapper, IDisposable
    {
        private readonly Func<Exception>? _failOnSend;
        private readonly Func<Exception>? _failOnAuthenticate;

        public DisconnectTrackingClient(
            Func<Exception>? failOnSend = null, Func<Exception>? failOnAuthenticate = null)
        {
            _failOnSend = failOnSend;
            _failOnAuthenticate = failOnAuthenticate;
        }

        public bool Disconnected { get; private set; }
        public bool Disposed { get; private set; }

        public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
            => Task.CompletedTask;

        public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
            => _failOnAuthenticate != null ? Task.FromException(_failOnAuthenticate()) : Task.CompletedTask;

        public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
            => _failOnSend != null ? Task.FromException(_failOnSend()) : Task.CompletedTask;

        public Task DisconnectAsync(CancellationToken cancellationToken = default)
        {
            Disconnected = true;
            return Task.CompletedTask;
        }

        public void Dispose() => Disposed = true;
    }

    [Fact]
    public async Task Send_TransientFailureDuringSend_StillDisconnectsClient()
    {
        // NS-010: DisconnectAsync used to run only on the success path inside the
        // try block. A failure in SendAsync left the authenticated connection open
        // (the SMTP QUIT was never issued), leaking server connection slots under
        // sustained transient failures.
        SetupHappyPath();
        var tracking = new DisconnectTrackingClient(
            failOnSend: () => new SmtpProtocolException("protocol error"));
        var service = new NotificationDeliveryService(
            _repository, () => tracking, NullLogger<NotificationDeliveryService>.Instance);

        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when the send fails");
    }

    [Fact]
    public async Task Send_FailureDuringAuthenticate_StillDisconnectsClient()
    {
        // NS-010: an AuthenticateAsync failure must also tear the connection down.
        SetupHappyPath();
        var tracking = new DisconnectTrackingClient(
            failOnAuthenticate: () => new SmtpProtocolException("auth handshake failed"));
        var service = new NotificationDeliveryService(
            _repository, () => tracking, NullLogger<NotificationDeliveryService>.Instance);

        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(tracking.Disconnected, "DeliverAsync must disconnect the SMTP client even when authentication fails");
    }

    // ── NotificationService-005: explicit TLS mode passed through to the wrapper ──

    /// <summary>An SMTP wrapper that records the TLS mode and timeout it was connected with.</summary>
    private sealed class RecordingTlsClient : ISmtpClientWrapper
    {
        public SmtpTlsMode? TlsMode { get; private set; }
        public int ConnectionTimeoutSeconds { get; private set; }
        public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
        {
            TlsMode = tlsMode;
            ConnectionTimeoutSeconds = connectionTimeoutSeconds;
            return Task.CompletedTask;
        }
        public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
    }

    private void SetupHappyPathWithSmtp(SmtpConfiguration smtpConfig)
    {
        var list = new NotificationList("ops-team") { Id = 1 };
        var recipients = new List<NotificationRecipient>
        {
            new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 }
        };
        _repository.GetListByNameAsync("ops-team").Returns(list);
        _repository.GetRecipientsByListIdAsync(1).Returns(recipients);
        _repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { smtpConfig });
    }

    [Fact]
    public async Task Send_TlsModeNone_DoesNotNegotiateTls()
    {
        // NS-005: TlsMode "none" must connect with SmtpTlsMode.None, not the old
        // SecureSocketOptions.Auto (which let MailKit opportunistically negotiate TLS).
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 25, Credentials = "user:pass", TlsMode = "none"
        };
        SetupHappyPathWithSmtp(cfg);
        var recording = new RecordingTlsClient();
        var service = new NotificationDeliveryService(
            _repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.Equal(SmtpTlsMode.None, recording.TlsMode);
    }

    [Fact]
    public async Task Send_TlsModeSsl_UsesImplicitSsl()
    {
        // NS-005: TlsMode "ssl" (port 465 implicit TLS) must be honoured, not
        // collapsed into the same path as "none".
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 465, Credentials = "user:pass", TlsMode = "ssl"
        };
        SetupHappyPathWithSmtp(cfg);
        var recording = new RecordingTlsClient();
        var service = new NotificationDeliveryService(
            _repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.Equal(SmtpTlsMode.Ssl, recording.TlsMode);
    }

    [Fact]
    public async Task Send_UnknownTlsMode_ReturnsErrorNotSilentFallback()
    {
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "bogus"
        };
        SetupHappyPathWithSmtp(cfg);
        var service = new NotificationDeliveryService(
            _repository, () => new RecordingTlsClient(), NullLogger<NotificationDeliveryService>.Instance);

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("TLS mode", result.ErrorMessage);
    }

    // ── NotificationService-007: connection timeout passed through to the wrapper ──

    [Fact]
    public async Task Send_PassesConfiguredConnectionTimeoutToClient()
    {
        // NS-007: SmtpConfiguration.ConnectionTimeoutSeconds must reach the wrapper
        // so SmtpClient.Timeout is set; it was previously dead configuration.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
            ConnectionTimeoutSeconds = 17
        };
        SetupHappyPathWithSmtp(cfg);
        var recording = new RecordingTlsClient();
        var service = new NotificationDeliveryService(
            _repository, () => recording, NullLogger<NotificationDeliveryService>.Instance);

        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.Equal(17, recording.ConnectionTimeoutSeconds);
    }

    [Fact]
    public async Task Send_MaxConcurrentConnections_LimitsConcurrentDeliveries()
    {
        // NS-007: MaxConcurrentConnections must throttle concurrent SMTP deliveries.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
            MaxConcurrentConnections = 2
        };
        SetupHappyPathWithSmtp(cfg);

        var inFlight = 0;
        var maxObserved = 0;
        var gate = new SemaphoreSlim(0);
        var sync = new object();

        var service = new NotificationDeliveryService(
            _repository,
            () => new BlockingSmtpClient(
                onSend: async () =>
                {
                    lock (sync)
                    {
                        inFlight++;
                        if (inFlight > maxObserved) maxObserved = inFlight;
                    }
                    await gate.WaitAsync();
                    lock (sync) { inFlight--; }
                }),
            NullLogger<NotificationDeliveryService>.Instance);

        var sends = Enumerable.Range(0, 6)
            .Select(_ => service.SendAsync("ops-team", "Alert", "Body"))
            .ToList();

        // Give the throttled sends time to reach the SMTP send call.
        await Task.Delay(200);
        gate.Release(6);
        await Task.WhenAll(sends);

        Assert.True(maxObserved <= 2, $"Expected at most 2 concurrent deliveries, observed {maxObserved}");
    }

    private sealed class BlockingSmtpClient : ISmtpClientWrapper, IDisposable
    {
        private readonly Func<Task> _onSend;
        public BlockingSmtpClient(Func<Task> onSend) => _onSend = onSend;
        public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
            => _onSend();
        public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
        public void Dispose() { }
    }

    // ── NotificationService-008: recipient address validation ──

    [Fact]
    public async Task Send_MalformedRecipientAddress_ReturnsCleanError_DoesNotThrow()
    {
        // NS-008: a malformed recipient address previously caused MailboxAddress.Parse
        // to throw ParseException, which escaped SendAsync unhandled. It must now
        // produce a clean NotificationResult failure.
        var list = new NotificationList("ops-team") { Id = 1 };
        var recipients = new List<NotificationRecipient>
        {
            new("Alice", "alice@example.com") { Id = 1, NotificationListId = 1 },
            new("Bad", "not a valid address @@") { Id = 2, NotificationListId = 1 }
        };
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
        };
        _repository.GetListByNameAsync("ops-team").Returns(list);
        _repository.GetRecipientsByListIdAsync(1).Returns(recipients);
        _repository.GetAllSmtpConfigurationsAsync().Returns(new List<SmtpConfiguration> { cfg });

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("not a valid address @@", result.ErrorMessage);
    }

    // ── NotificationService-009: credential secrets scrubbed from logs/results ──

    [Fact]
    public async Task Send_PermanentError_RedactsCredentialFromResultMessage()
    {
        // NS-009: a permanent-failure message echoing a credential fragment must be
        // scrubbed before it reaches the caller-facing NotificationResult.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new SmtpPermanentException("550 rejected — password Hunter2Secret is invalid"));

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
    }

    [Fact]
    public async Task Send_MalformedFromAddress_ReturnsCleanError_DoesNotThrow()
    {
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "@@bad-from@@")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.Contains("address", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
    }

    // ── NotificationService-012: OAuth2 delivery path coverage ──

    /// <summary>An SMTP wrapper that records the auth type and credentials it received.</summary>
    private sealed class RecordingAuthClient : ISmtpClientWrapper
    {
        public string? AuthType { get; private set; }
        public string? Credentials { get; private set; }
        public Task ConnectAsync(string host, int port, SmtpTlsMode tlsMode, int connectionTimeoutSeconds, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
        {
            AuthType = authType;
            Credentials = credentials;
            return Task.CompletedTask;
        }
        public Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
            => Task.CompletedTask;
        public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask;
    }

    private static OAuth2TokenService CreateTokenService(string accessToken, int expiresIn = 3600)
    {
        var json = JsonSerializer.Serialize(new
        {
            access_token = accessToken,
            expires_in = expiresIn,
            token_type = "Bearer"
        });
        var factory = Substitute.For<IHttpClientFactory>();
        factory.CreateClient(Arg.Any<string>())
            .Returns(_ => new HttpClient(new StubHttpHandler(json)));
        return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
    }

    private sealed class StubHttpHandler : HttpMessageHandler
    {
        private readonly string _json;
        public StubHttpHandler(string json) => _json = json;
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
            => Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
            {
                Content = new StringContent(_json)
            });
    }

    [Fact]
    public async Task Send_OAuth2Config_AuthenticatesWithResolvedAccessToken()
    {
        // NS-012: the OAuth2 delivery branch in DeliverAsync (token resolution during
        // a send) was never exercised — every other test uses Basic Auth and a null
        // token service. The credentials reaching the SMTP client must be the access
        // token from OAuth2TokenService, not the raw tenant:client:secret triple.
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var recording = new RecordingAuthClient();
        var service = new NotificationDeliveryService(
            _repository,
            () => recording,
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateTokenService("oauth2-access-token-xyz"));

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.True(result.Success);
        Assert.Equal("oauth2", recording.AuthType);
        Assert.Equal("oauth2-access-token-xyz", recording.Credentials);
    }

    // ── NotificationService-015: unclassified exceptions must not escape SendAsync ──

    /// <summary>
    /// An <see cref="OAuth2TokenService"/> whose token endpoint returns a non-success
    /// HTTP status, so <c>GetTokenAsync</c> throws <see cref="HttpRequestException"/>.
    /// </summary>
    private static OAuth2TokenService CreateFailingTokenService(
        HttpStatusCode status = HttpStatusCode.Unauthorized)
    {
        var factory = Substitute.For<IHttpClientFactory>();
        factory.CreateClient(Arg.Any<string>())
            .Returns(_ => new HttpClient(new FailingHttpHandler(status)));
        return new OAuth2TokenService(factory, NullLogger<OAuth2TokenService>.Instance);
    }

    private sealed class FailingHttpHandler : HttpMessageHandler
    {
        private readonly System.Net.HttpStatusCode _status;
        public FailingHttpHandler(System.Net.HttpStatusCode status) => _status = status;
        protected override Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
            => Task.FromResult(new HttpResponseMessage(_status)
            {
                Content = new StringContent("error")
            });
    }

    [Fact]
    public async Task Send_OAuth2TokenFetchFails_ReturnsCleanError_DoesNotThrow()
    {
        // NS-015: an OAuth2 token-fetch failure (HttpRequestException from
        // EnsureSuccessStatusCode) is classified Unknown — it fell through all three
        // catch clauses and escaped SendAsync as a raw exception to the calling
        // script. It must instead produce a clean NotificationResult failure.
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = new NotificationDeliveryService(
            _repository,
            () => new RecordingAuthClient(),
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateFailingTokenService());

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.NotNull(result.ErrorMessage);
    }

    [Fact]
    public async Task Send_OAuth2MalformedCredentials_ReturnsCleanError_DoesNotThrow()
    {
        // NS-015: a malformed tenant:client:secret triple makes GetTokenAsync throw
        // InvalidOperationException — also Unknown-classified, also escaped SendAsync.
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = new NotificationDeliveryService(
            _repository,
            () => new RecordingAuthClient(),
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateTokenService("unused"));

        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.NotNull(result.ErrorMessage);
    }

    [Fact]
    public async Task Send_UnclassifiedException_RedactsCredentialFromResult()
    {
        // NS-015: the catch-all result, like the permanent-error path (NS-009), must
        // not leak credential fragments echoed in an exception message.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "svcuser:Hunter2Secret", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);
        _smtpClient.SendAsync(Arg.Any<string>(), Arg.Any<IEnumerable<string>>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
            .Throws(new InvalidOperationException("internal failure exposing Hunter2Secret"));

        var service = CreateService();
        var result = await service.SendAsync("ops-team", "Alert", "Body");

        Assert.False(result.Success);
        Assert.DoesNotContain("Hunter2Secret", result.ErrorMessage);
    }

    // ── NotificationService-014: unclassified exceptions must not escape DeliverBufferedAsync ──

    [Fact]
    public async Task DeliverBuffered_OAuth2MalformedCredentials_ReturnsFalseSoMessageParks()
    {
        // NS-014: DeliverBufferedAsync caught only SmtpPermanentException. An OAuth2
        // InvalidOperationException (a permanent, unfixable misconfiguration) escaped
        // the handler; the S&F engine reinterprets any thrown exception as transient
        // and retries forever. A non-retryable cause must park (return false).
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "no-colons-here", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = new NotificationDeliveryService(
            _repository,
            () => new RecordingAuthClient(),
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateTokenService("unused"));

        var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));

        Assert.False(delivered); // parked — retrying cannot fix a malformed credential
    }

    [Fact]
    public async Task DeliverBuffered_OAuth2TokenEndpoint401_ReturnsFalseSoMessageParks()
    {
        // NS-014: a 401 from the OAuth2 token endpoint is a permanent credential
        // failure — it must park, not be retried on every sweep.
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = new NotificationDeliveryService(
            _repository,
            () => new RecordingAuthClient(),
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateFailingTokenService(HttpStatusCode.Unauthorized));

        var delivered = await service.DeliverBufferedAsync(BufferedNotification("ops-team"));

        Assert.False(delivered); // parked — a 401 is a permanent credential failure
    }

    [Fact]
    public async Task DeliverBuffered_OAuth2TokenEndpoint503_ThrowsSoEngineRetries()
    {
        // NS-014: a 5xx from the OAuth2 token endpoint is a transient outage — the
        // handler must throw so the S&F engine retries on the next sweep.
        var cfg = new SmtpConfiguration("smtp.office365.com", "oauth2", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "tenant1:client1:secret1", TlsMode = "starttls"
        };
        SetupHappyPathWithSmtp(cfg);

        var service = new NotificationDeliveryService(
            _repository,
            () => new RecordingAuthClient(),
            NullLogger<NotificationDeliveryService>.Instance,
            tokenService: CreateFailingTokenService(HttpStatusCode.ServiceUnavailable));

        await Assert.ThrowsAnyAsync<Exception>(
            () => service.DeliverBufferedAsync(BufferedNotification("ops-team")));
    }

    // ── NotificationService-017: NotificationOptions used as fallback for unset SmtpConfiguration fields ──

    [Fact]
    public async Task Send_SmtpConfigTimeoutUnset_FallsBackToNotificationOptions()
    {
        // NS-017: NotificationOptions was bound from configuration but never read.
        // It is now the documented fallback: when SmtpConfiguration.ConnectionTimeoutSeconds
        // is non-positive (0 from a partially-deployed row) the NotificationOptions
        // value is used instead of leaving the timeout unbounded.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
            ConnectionTimeoutSeconds = 0 // not configured on the row
        };
        SetupHappyPathWithSmtp(cfg);
        var recording = new RecordingTlsClient();

        var options = Microsoft.Extensions.Options.Options.Create(
            new NotificationOptions { ConnectionTimeoutSeconds = 42, MaxConcurrentConnections = 5 });
        var service = new NotificationDeliveryService(
            _repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
            options: options);

        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.Equal(42, recording.ConnectionTimeoutSeconds);
    }

    [Fact]
    public async Task Send_SmtpConfigTimeoutSet_OverridesNotificationOptions()
    {
        // NS-017: a value present on the SmtpConfiguration row still wins over the
        // NotificationOptions fallback.
        var cfg = new SmtpConfiguration("smtp.example.com", "basic", "noreply@example.com")
        {
            Id = 1, Port = 587, Credentials = "user:pass", TlsMode = "starttls",
            ConnectionTimeoutSeconds = 19
        };
        SetupHappyPathWithSmtp(cfg);
        var recording = new RecordingTlsClient();

        var options = Microsoft.Extensions.Options.Options.Create(
            new NotificationOptions { ConnectionTimeoutSeconds = 42 });
        var service = new NotificationDeliveryService(
            _repository, () => recording, NullLogger<NotificationDeliveryService>.Instance,
            options: options);

        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.Equal(19, recording.ConnectionTimeoutSeconds);
    }

    // ── NotificationService-018: concurrency limiter disposal ──

    [Fact]
    public async Task Service_Dispose_DisposesConcurrencyLimiter()
    {
        // NS-018: the lazily-created SemaphoreSlim was never disposed and the service
        // did not implement IDisposable — a slow handle leak per scope. Disposing the
        // service must dispose the limiter; using it afterwards must fault.
        SetupHappyPath();
        var service = CreateService();

        // A send creates the limiter.
        await service.SendAsync("ops-team", "Alert", "Body");

        Assert.IsAssignableFrom<IDisposable>(service);
        ((IDisposable)service).Dispose();

        // A second send after disposal must fail fast on the disposed semaphore
        // rather than silently using a disposed object.
        await Assert.ThrowsAnyAsync<ObjectDisposedException>(
            () => service.SendAsync("ops-team", "Alert", "Body"));
    }
}
