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_NotPromotedToPermanent()
    {
        // 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 NotificationResult. Classification now uses MailKit's
        // typed exceptions only, so a non-SMTP exception is no longer misclassified.
        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();

        // The exception is not classified at all (not a typed SMTP failure); it
        // surfaces rather than being mistaken for a permanent 5xx rejection.
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.SendAsync("ops-team", "Alert", "Body"));
    }

    [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-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);
    }
}
