using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.NotificationOutbox.Delivery;

namespace ZB.MOM.WW.ScadaBridge.NotificationOutbox.Tests.Delivery;

/// <summary>
/// Tests for the Twilio-REST SMS outbox delivery adapter (T9 pivot) — list/recipient/
/// SMS-config resolution, the per-recipient Twilio POST, the per-attempt
/// <see cref="SmsErrorClassifier"/> classification, the D6 roll-up, and the hard
/// invariant that the Twilio Auth Token never leaks into a returned outcome.
/// </summary>
public class SmsNotificationDeliveryAdapterTests
{
    private const string AuthToken = "super-secret-auth-token-abcdef0123456789";

    private readonly INotificationRepository _repository = Substitute.For<INotificationRepository>();
    private readonly IHttpClientFactory _httpClientFactory = Substitute.For<IHttpClientFactory>();

    /// <summary>
    /// A scriptable <see cref="HttpMessageHandler"/> that returns a queued sequence of
    /// responses (one per recipient POST) and records every outbound request so the
    /// tests can assert the URL / auth header / form body.
    /// </summary>
    private sealed class ScriptedHttpMessageHandler : HttpMessageHandler
    {
        private readonly Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>> _responders;

        public List<HttpRequestMessage> Requests { get; } = new();
        public List<string> RequestBodies { get; } = new();

        private ScriptedHttpMessageHandler(IEnumerable<Func<HttpRequestMessage, Task<HttpResponseMessage>>> responders)
        {
            _responders = new Queue<Func<HttpRequestMessage, Task<HttpResponseMessage>>>(responders);
        }

        /// <summary>One queued response per status code (one per recipient POST).</summary>
        public static ScriptedHttpMessageHandler ForStatuses(params HttpStatusCode[] statusCodes) =>
            new(statusCodes.Select(code =>
                new Func<HttpRequestMessage, Task<HttpResponseMessage>>(
                    _ => Task.FromResult(new HttpResponseMessage(code)
                    {
                        Content = new StringContent("{}", Encoding.UTF8, "application/json"),
                    }))));

        /// <summary>Custom per-request responders (e.g. to throw a transport exception).</summary>
        public static ScriptedHttpMessageHandler ForResponders(
            params Func<HttpRequestMessage, Task<HttpResponseMessage>>[] responders) => new(responders);

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            Requests.Add(request);
            RequestBodies.Add(request.Content == null
                ? string.Empty
                : await request.Content.ReadAsStringAsync(cancellationToken));

            if (_responders.Count == 0)
            {
                throw new InvalidOperationException("No scripted response left for request.");
            }

            return await _responders.Dequeue()(request);
        }
    }

    private SmsNotificationDeliveryAdapter CreateAdapter(HttpMessageHandler handler, SmsOptions? options = null)
    {
        // Pin to the real named-client name so tests catch accidental renames.
        _httpClientFactory
            .CreateClient(SmsNotificationDeliveryAdapter.HttpClientName)
            .Returns(_ => new HttpClient(handler));
        return new SmsNotificationDeliveryAdapter(
            _repository,
            _httpClientFactory,
            NullLogger<SmsNotificationDeliveryAdapter>.Instance,
            Options.Create(options ?? new SmsOptions()));
    }

    private static Notification MakeNotification(string listName = "ops-team")
    {
        return new Notification(
            Guid.NewGuid().ToString(),
            NotificationType.Sms,
            listName,
            "Subject",
            "Body",
            "site-1");
    }

    private void SetupList(
        string name = "ops-team",
        IReadOnlyList<string>? phones = null,
        SmsConfiguration? config = null)
    {
        phones ??= new[] { "+15551112222" };
        var list = new NotificationList(name) { Id = 1, Type = NotificationType.Sms };
        var recipients = phones
            .Select((p, i) => { var r = NotificationRecipient.ForSms($"R{i}", p); r.Id = i + 1; r.NotificationListId = 1; return r; })
            .ToList();

        _repository.GetListByNameAsync(name, Arg.Any<CancellationToken>()).Returns(list);
        _repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>()).Returns(recipients);
        _repository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
            .Returns(config ?? new SmsConfiguration("AC_account_sid", "+15550000000")
            {
                Id = 1,
                AuthToken = AuthToken,
                ApiBaseUrl = "https://fake-twilio.test",
            });
    }

    [Fact]
    public void Type_IsSms()
    {
        Assert.Equal(NotificationType.Sms, CreateAdapter(ScriptedHttpMessageHandler.ForStatuses()).Type);
    }

    [Fact]
    public async Task Deliver_SingleRecipient201_ReturnsSuccessWithResolvedTargets()
    {
        SetupList(phones: new[] { "+15551112222" });
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.Success, outcome.Result);
        Assert.NotNull(outcome.ResolvedTargets);
        Assert.Contains("+15551112222", outcome.ResolvedTargets);
        Assert.Null(outcome.Error);

        // One POST to the Twilio Messages endpoint with the account SID on the path,
        // a Basic auth header, and a form-encoded To / From / Body.
        var request = Assert.Single(handler.Requests);
        Assert.Equal(HttpMethod.Post, request.Method);
        Assert.Equal(
            "https://fake-twilio.test/2010-04-01/Accounts/AC_account_sid/Messages.json",
            request.RequestUri!.ToString());
        Assert.Equal("Basic", request.Headers.Authorization!.Scheme);
        var body = Assert.Single(handler.RequestBodies);
        Assert.Contains("To=", body);
        Assert.Contains("From=", body);
        Assert.Contains("Body=", body);

        // The factory must be asked for the exact named client the adapter is documented
        // to use, not an arbitrary string.
        _httpClientFactory.Received(1).CreateClient(SmsNotificationDeliveryAdapter.HttpClientName);
    }

    [Fact]
    public async Task Deliver_MultiRecipient201_AllAccepted_ReturnsSuccess()
    {
        SetupList(phones: new[] { "+15551112222", "+15553334444" });
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.Created);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.Success, outcome.Result);
        Assert.Contains("+15551112222", outcome.ResolvedTargets);
        Assert.Contains("+15553334444", outcome.ResolvedTargets);
        Assert.Equal(2, handler.Requests.Count);
    }

    [Fact]
    public async Task Deliver_UsesMessagingServiceSid_WhenFromNumberAbsent()
    {
        var config = new SmsConfiguration("AC_account_sid", "")
        {
            Id = 1,
            AuthToken = AuthToken,
            MessagingServiceSid = "MG_messaging_service",
            ApiBaseUrl = "https://fake-twilio.test",
        };
        SetupList(config: config);
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.Success, outcome.Result);
        var body = Assert.Single(handler.RequestBodies);
        Assert.Contains("MessagingServiceSid=MG_messaging_service", body);
        Assert.DoesNotContain("From=", body);
    }

    [Fact]
    public async Task Deliver_500_ReturnsTransient()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.InternalServerError);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
        Assert.NotNull(outcome.Error);
    }

    [Fact]
    public async Task Deliver_429_ReturnsTransient()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForStatuses((HttpStatusCode)429);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
    }

    [Fact]
    public async Task Deliver_HttpRequestException_ReturnsTransient()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForResponders(
            _ => throw new HttpRequestException("connection refused"));
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
    }

    [Fact]
    public async Task Deliver_Timeout_ReturnsTransient()
    {
        SetupList();
        // HttpClient surfaces a per-request timeout as a TaskCanceledException with no
        // caller cancel; the classifier treats that as transient. Simulate it directly.
        var handler = ScriptedHttpMessageHandler.ForResponders(
            _ => throw new TaskCanceledException("The request was canceled due to timeout."));
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
    }

    [Fact]
    public async Task Deliver_400_ReturnsPermanent()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.NotNull(outcome.Error);
    }

    [Fact]
    public async Task Deliver_401_ReturnsPermanent()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Unauthorized);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
    }

    [Fact]
    public async Task Deliver_MixAcceptedAndPermanentBad_ReturnsSuccessWithBadNumberNoted()
    {
        SetupList(phones: new[] { "+15551112222", "+1BADNUMBER" });
        // First number accepted (201), second permanently rejected (400).
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created, HttpStatusCode.BadRequest);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        // D6: anything got through → Success (do NOT park); the bad number is noted.
        Assert.Equal(DeliveryResult.Success, outcome.Result);
        Assert.Contains("+15551112222", outcome.ResolvedTargets);
        Assert.Contains("+1BADNUMBER", outcome.ResolvedTargets);
    }

    [Fact]
    public async Task Deliver_MixAcceptedAndTransient_ReturnsTransient()
    {
        SetupList(phones: new[] { "+15551112222", "+15553334444" });
        // First accepted, second transient (503): D6 says any transient → whole notification retries.
        var handler = ScriptedHttpMessageHandler.ForStatuses(
            HttpStatusCode.Created, HttpStatusCode.ServiceUnavailable);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
    }

    [Fact]
    public async Task MidListTransient_ShortCircuits_RemainingRecipientsNotAttempted()
    {
        // Three recipients; the FIRST one comes back transient (500). Every recipient
        // accepted after a transient is a guaranteed duplicate on retry (the whole
        // notification re-sends), so the adapter must stop at the first transient
        // instead of attempting the remaining two.
        SetupList(phones: new[] { "+15551112222", "+15553334444", "+15555556666" });
        var handler = ScriptedHttpMessageHandler.ForStatuses(
            HttpStatusCode.InternalServerError, HttpStatusCode.Created, HttpStatusCode.Created);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
        Assert.Single(handler.Requests);
    }

    [Fact]
    public async Task Deliver_ListNotFound_ReturnsPermanent()
    {
        _repository.GetListByNameAsync("missing", Arg.Any<CancellationToken>())
            .Returns((NotificationList?)null);
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());

        var outcome = await adapter.DeliverAsync(MakeNotification("missing"));

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.Contains("missing", outcome.Error);
        Assert.Contains("not found", outcome.Error);
    }

    [Fact]
    public async Task Deliver_NoPhoneRecipients_ReturnsPermanent()
    {
        var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
        // A recipient with only an email and no phone is not SMS-eligible.
        _repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
        _repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<NotificationRecipient>
            {
                NotificationRecipient.ForEmail("Alice", "alice@example.com"),
            });
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.Contains("no SMS recipients", outcome.Error);
    }

    [Fact]
    public async Task Deliver_NoSmsConfig_ReturnsPermanent()
    {
        var list = new NotificationList("ops-team") { Id = 1, Type = NotificationType.Sms };
        _repository.GetListByNameAsync("ops-team", Arg.Any<CancellationToken>()).Returns(list);
        _repository.GetRecipientsByListIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<NotificationRecipient> { NotificationRecipient.ForSms("R", "+15551112222") });
        _repository.GetSmsConfigurationAsync(Arg.Any<CancellationToken>())
            .Returns((SmsConfiguration?)null);
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.Contains("SMS configuration", outcome.Error);
    }

    [Fact]
    public async Task Deliver_IncompleteConfig_MissingAuthToken_ReturnsPermanent()
    {
        var config = new SmsConfiguration("AC_account_sid", "+15550000000")
        {
            Id = 1,
            AuthToken = null, // missing secret
            ApiBaseUrl = "https://fake-twilio.test",
        };
        SetupList(config: config);
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.Contains("incomplete", outcome.Error);
    }

    [Fact]
    public async Task Deliver_IncompleteConfig_NoFromAndNoMessagingServiceSid_ReturnsPermanent()
    {
        // AccountSid + AuthToken are present but BOTH FromNumber and MessagingServiceSid
        // are absent — the adapter cannot build a valid Twilio request. This is a
        // configuration error that cannot be resolved by retrying; expect Permanent.
        var config = new SmsConfiguration("AC_account_sid", "") // empty FromNumber
        {
            Id = 1,
            AuthToken = AuthToken,
            MessagingServiceSid = null, // no fallback either
            ApiBaseUrl = "https://fake-twilio.test",
        };
        SetupList(config: config);
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses());

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.PermanentFailure, outcome.Result);
        Assert.Contains("incomplete", outcome.Error);
    }

    [Fact]
    public async Task Deliver_DefaultsToTwilioBaseUrl_WhenApiBaseUrlAbsent()
    {
        var config = new SmsConfiguration("AC_account_sid", "+15550000000")
        {
            Id = 1,
            AuthToken = AuthToken,
            ApiBaseUrl = null, // falls back to https://api.twilio.com
        };
        SetupList(config: config);
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
        var adapter = CreateAdapter(handler);

        await adapter.DeliverAsync(MakeNotification());

        var request = Assert.Single(handler.Requests);
        Assert.StartsWith("https://api.twilio.com/2010-04-01/Accounts/", request.RequestUri!.ToString());
    }

    [Fact]
    public async Task Deliver_AuthToken_NeverAppearsInReturnedError()
    {
        SetupList();
        // A permanent failure (400) so the outcome carries an Error string.
        // NOTE: the HTTP-error detail string ("Twilio returned HTTP 400…") is built
        // without ever embedding the credential, so the primary guarantee here is
        // documentation of intent. The CredentialRedactor.Scrub call still runs as
        // defense-in-depth in case the detail format ever changes.
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.BadRequest);
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.NotNull(outcome.Error);

        // Bare token must not appear.
        Assert.DoesNotContain(AuthToken, outcome.Error);

        // The "AccountSid:AuthToken" composite (the Basic-auth credential before
        // base64 encoding) must not appear — tests the defense-in-depth Scrub.
        const string accountSid = "AC_account_sid";
        var composite = $"{accountSid}:{AuthToken}";
        Assert.DoesNotContain(composite, outcome.Error);

        // The base64-encoded form of the composite must not appear either.
        var base64Credential = Convert.ToBase64String(
            System.Text.Encoding.ASCII.GetBytes(composite));
        Assert.DoesNotContain(base64Credential, outcome.Error);
    }

    [Fact]
    public async Task Deliver_AuthToken_NeverAppearsOnTransportException()
    {
        SetupList();
        // The exception message echoes the auth token; the adapter must scrub it.
        var handler = ScriptedHttpMessageHandler.ForResponders(
            _ => throw new HttpRequestException($"auth failed with token {AuthToken}"));
        var adapter = CreateAdapter(handler);

        var outcome = await adapter.DeliverAsync(MakeNotification());

        Assert.Equal(DeliveryResult.TransientFailure, outcome.Result);
        Assert.NotNull(outcome.Error);
        Assert.DoesNotContain(AuthToken, outcome.Error);
    }

    [Fact]
    public async Task Deliver_LongBody_TruncatedToMaxMessageLength()
    {
        SetupList();
        var handler = ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created);
        var adapter = CreateAdapter(handler, new SmsOptions { MaxMessageLength = 20 });

        var longNotification = new Notification(
            Guid.NewGuid().ToString(), NotificationType.Sms, "ops-team",
            "Subject", new string('x', 500), "site-1");

        var outcome = await adapter.DeliverAsync(longNotification);

        Assert.Equal(DeliveryResult.Success, outcome.Result);
        // The encoded form body's Body value must not exceed the cap (form-encoding
        // length aside, the raw text was truncated to 20 chars incl. ellipsis).
        var body = Assert.Single(handler.RequestBodies);
        Assert.Contains("Body=", body);
        // The 500-char run cannot survive a 20-char cap.
        Assert.DoesNotContain(new string('x', 100), body);
    }

    [Fact]
    public async Task Deliver_CallerCancellation_Propagates()
    {
        SetupList();
        using var cts = new CancellationTokenSource();
        cts.Cancel();
        var adapter = CreateAdapter(ScriptedHttpMessageHandler.ForStatuses(HttpStatusCode.Created));

        await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => adapter.DeliverAsync(MakeNotification(), cts.Token));
    }
}
