using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
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.NotificationService;

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

/// <summary>
/// SMS channel delivery adapter for the central notification outbox.
///
/// Calls Twilio directly over its REST API — no SDK — honouring the project's
/// no-new-NuGet-package rule. The adapter resolves the notification's list, keeps
/// only SMS-eligible recipients (non-empty <see cref="NotificationRecipient.PhoneNumber"/>),
/// loads the central <see cref="SmsConfiguration"/>, composes a plain-text body, and
/// issues one <c>POST .../Messages.json</c> per recipient (Twilio has no BCC). Each
/// response is classified via <see cref="SmsErrorClassifier"/> and rolled up into the
/// outbox's three-way <see cref="DeliveryOutcome"/>.
/// <para>
/// Mirrors the Email adapter's structure (load list → recipients → config, build
/// message, classify outcome). "Accepted by Twilio" (HTTP 2xx) is treated as
/// Delivered, exactly as the Email adapter treats "accepted by the SMTP server";
/// true delivery confirmation needs a status-callback webhook (out of scope).
/// </para>
/// <para>
/// The Twilio Auth Token is NEVER allowed to appear in a returned
/// <see cref="DeliveryOutcome.Error"/> or in any log: every outbound error string is
/// passed through <see cref="CredentialRedactor.Scrub"/> with the token before it is
/// logged or returned.
/// </para>
/// </summary>
public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapter
{
    /// <summary>Provider default Twilio REST base URL, used when the config row leaves <c>ApiBaseUrl</c> unset.</summary>
    private const string DefaultApiBaseUrl = "https://api.twilio.com";

    /// <summary>The named <see cref="HttpClient"/> registered for Twilio in <c>ServiceCollectionExtensions</c>.</summary>
    public const string HttpClientName = "Twilio";

    private readonly INotificationRepository _repository;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<SmsNotificationDeliveryAdapter> _logger;
    private readonly SmsOptions _options;

    /// <summary>Initializes a new instance of <see cref="SmsNotificationDeliveryAdapter"/>.</summary>
    /// <param name="repository">Repository for resolving notification list recipients and the SMS configuration.</param>
    /// <param name="httpClientFactory">Factory creating the named <c>"Twilio"</c> HTTP client per delivery attempt.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="options">SMS options providing the documented message-length cap and timeout fallback.</param>
    public SmsNotificationDeliveryAdapter(
        INotificationRepository repository,
        IHttpClientFactory httpClientFactory,
        ILogger<SmsNotificationDeliveryAdapter> logger,
        IOptions<SmsOptions> options)
    {
        _repository = repository;
        _httpClientFactory = httpClientFactory;
        _logger = logger;
        // SmsOptions supplies the documented body cap and the timeout fallback used
        // when a deployed SmsConfiguration row leaves ConnectionTimeoutSeconds unset.
        _options = options?.Value ?? new SmsOptions();
    }

    /// <inheritdoc />
    public NotificationType Type => NotificationType.Sms;

    /// <inheritdoc />
    public async Task<DeliveryOutcome> DeliverAsync(
        Notification notification, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(notification);

        var list = await _repository.GetListByNameAsync(notification.ListName, cancellationToken);
        if (list == null)
        {
            return DeliveryOutcome.Permanent(
                $"Notification list '{notification.ListName}' not found");
        }

        var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
        var phoneNumbers = recipients
            .Where(r => !string.IsNullOrWhiteSpace(r.PhoneNumber))
            .Select(r => r.PhoneNumber!.Trim())
            .Distinct(StringComparer.Ordinal)
            .ToList();
        if (phoneNumbers.Count == 0)
        {
            return DeliveryOutcome.Permanent(
                $"Notification list '{notification.ListName}' has no SMS recipients");
        }

        var smsConfig = await _repository.GetSmsConfigurationAsync(cancellationToken);
        if (smsConfig == null)
        {
            return DeliveryOutcome.Permanent("No SMS configuration available");
        }

        // The Account SID + Auth Token are mandatory (they form the Basic-auth header
        // and the SID is on the URL path). A From number is required UNLESS a
        // Messaging Service SID is supplied, in which case From is optional. None of
        // these can be fixed by retrying, so a missing field is a permanent failure.
        if (string.IsNullOrWhiteSpace(smsConfig.AccountSid)
            || string.IsNullOrWhiteSpace(smsConfig.AuthToken))
        {
            return DeliveryOutcome.Permanent(
                "SMS configuration is incomplete: AccountSid and AuthToken are required");
        }

        var hasMessagingService = !string.IsNullOrWhiteSpace(smsConfig.MessagingServiceSid);
        if (!hasMessagingService && string.IsNullOrWhiteSpace(smsConfig.FromNumber))
        {
            return DeliveryOutcome.Permanent(
                "SMS configuration is incomplete: a FromNumber or MessagingServiceSid is required");
        }

        var authToken = smsConfig.AuthToken!;
        // Defense-in-depth: build the full Basic-auth credential string so that BOTH
        // the bare AuthToken AND the "AccountSid:AuthToken" composite are guaranteed
        // scrubbed from any returned / logged error. CredentialRedactor.Scrub redacts
        // the whole string it is given AND its last colon-separated component (when
        // >= MinSecretLength), so one call with the composite covers both shapes.
        var redactSecret = $"{smsConfig.AccountSid}:{authToken}";
        var body = ComposeBody(notification.Subject, notification.Body);

        var baseUrl = string.IsNullOrWhiteSpace(smsConfig.ApiBaseUrl)
            ? DefaultApiBaseUrl
            : smsConfig.ApiBaseUrl!.TrimEnd('/');
        var requestUri = $"{baseUrl}/2010-04-01/Accounts/{smsConfig.AccountSid}/Messages.json";

        // Config-row value overrides the SmsOptions fallback for the per-request timeout.
        var timeoutSeconds = smsConfig.ConnectionTimeoutSeconds > 0
            ? smsConfig.ConnectionTimeoutSeconds
            : _options.ConnectionTimeoutSeconds;

        var authHeader = new AuthenticationHeaderValue(
            "Basic",
            Convert.ToBase64String(Encoding.ASCII.GetBytes($"{smsConfig.AccountSid}:{authToken}")));

        var httpClient = _httpClientFactory.CreateClient(HttpClientName);

        var accepted = new List<string>();
        var permanentlyFailed = new List<string>();
        string? firstTransientError = null;

        foreach (var phone in phoneNumbers)
        {
            // A caller-requested cancellation propagates; it is neither a success nor
            // a delivery failure (mirrors the Email adapter).
            cancellationToken.ThrowIfCancellationRequested();

            var attempt = await SendOneAsync(
                httpClient, requestUri, authHeader, smsConfig, hasMessagingService,
                phone, body, timeoutSeconds, redactSecret, cancellationToken);

            if (attempt.Class == SmsErrorClass.Transient)
            {
                // Every recipient accepted after the first transient is a guaranteed
                // duplicate on retry (the whole notification re-sends); stopping here
                // also bounds a black-holed endpoint to one timeout per sweep.
                firstTransientError = attempt.Detail;
                break;
            }

            if (attempt.Class == SmsErrorClass.Permanent)
            {
                permanentlyFailed.Add(phone);
            }
            else
            {
                accepted.Add(phone);
            }
        }

        return RollUp(notification.ListName, accepted, permanentlyFailed, firstTransientError);
    }

    /// <summary>
    /// Issues one Twilio POST for a single recipient and classifies the result.
    /// Transport exceptions are caught and classified here; a caller-requested
    /// cancellation is re-thrown so it propagates out of <see cref="DeliverAsync"/>.
    /// </summary>
    private async Task<SmsAttempt> SendOneAsync(
        HttpClient httpClient,
        string requestUri,
        AuthenticationHeaderValue authHeader,
        SmsConfiguration smsConfig,
        bool hasMessagingService,
        string toNumber,
        string body,
        int timeoutSeconds,
        string redactSecret,
        CancellationToken cancellationToken)
    {
        // Per-request timeout layered over the caller's token via a linked CTS, so a
        // slow Twilio response is classified transient (HttpClient surfaces the cancel
        // as a TaskCanceledException with no caller-cancel) while a genuine caller
        // cancel still propagates.
        using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
            cancellationToken, timeoutCts.Token);

        try
        {
            var form = new List<KeyValuePair<string, string>>
            {
                new("To", toNumber),
                new("Body", body),
            };
            if (hasMessagingService)
            {
                form.Add(new KeyValuePair<string, string>(
                    "MessagingServiceSid", smsConfig.MessagingServiceSid!));
            }
            else
            {
                // The line 113 guard guarantees a non-null FromNumber whenever there is no
                // MessagingServiceSid, so this branch never sends a null From.
                form.Add(new KeyValuePair<string, string>("From", smsConfig.FromNumber!));
            }

            using var request = new HttpRequestMessage(HttpMethod.Post, requestUri)
            {
                Content = new FormUrlEncodedContent(form),
            };
            request.Headers.Authorization = authHeader;

            using var response = await httpClient.SendAsync(request, linkedCts.Token);

            if (response.IsSuccessStatusCode)
            {
                return new SmsAttempt(SmsErrorClass.Unknown, null);
            }

            var cls = SmsErrorClassifier.Classify(response.StatusCode);
            var detail = CredentialRedactor.Scrub(
                $"Twilio returned HTTP {(int)response.StatusCode} ({response.StatusCode}) for {toNumber}",
                redactSecret);

            if (cls == SmsErrorClass.Transient)
            {
                _logger.LogWarning(
                    "Transient SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
            }
            else
            {
                _logger.LogWarning(
                    "Permanent SMS failure delivering to {Recipient}: {Detail}", toNumber, detail);
            }

            return new SmsAttempt(cls, detail);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // A caller-requested cancellation propagates; the per-request timeout
            // cancel (timeoutCts) is NOT a caller cancel and falls through to the
            // transient classifier below.
            throw;
        }
        catch (Exception ex) when (SmsErrorClassifier.IsTransient(ex, cancellationToken))
        {
            var detail = CredentialRedactor.Scrub(
                $"SMS transport failure for {toNumber} ({ex.GetType().Name}): {ex.Message}",
                redactSecret);
            _logger.LogWarning(
                "Transient SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
                toNumber, ex.GetType().Name, detail);
            return new SmsAttempt(SmsErrorClass.Transient, detail);
        }
        catch (Exception ex)
        {
            // An unclassified failure is treated as permanent: retrying a malformed
            // request burns Twilio calls (mirrors the Email adapter's default-permanent
            // stance for unclassified failures).
            var detail = CredentialRedactor.Scrub(
                $"SMS delivery failed for {toNumber} ({ex.GetType().Name}): {ex.Message}",
                redactSecret);
            _logger.LogError(
                "Unclassified SMS failure delivering to {Recipient} ({ExceptionType}): {Detail}",
                toNumber, ex.GetType().Name, detail);
            return new SmsAttempt(SmsErrorClass.Permanent, detail);
        }
    }

    /// <summary>
    /// Rolls the per-recipient outcomes up into a single notification-level outcome:
    /// any transient → Transient (whole notification retries); else any
    /// accepted → Success (the permanently-failed numbers are noted, NOT parked); else
    /// (all permanent / zero accepted) → Permanent.
    /// <para>
    /// The caller's recipient loop stops at the FIRST transient rather than attempting
    /// every recipient: every recipient accepted after the first transient is a
    /// guaranteed duplicate on retry (the whole notification re-sends), so
    /// <paramref name="accepted"/> and <paramref name="permanentlyFailed"/> only ever
    /// reflect recipients attempted before that first transient.
    /// </para>
    /// </summary>
    private DeliveryOutcome RollUp(
        string listName,
        IReadOnlyList<string> accepted,
        IReadOnlyList<string> permanentlyFailed,
        string? firstTransientError)
    {
        // Any transient failure parks the WHOLE notification for retry at the fixed
        // interval; numbers already accepted on this attempt get re-texted on retry
        // (the same "re-send to all" characteristic the Email/BCC path already has).
        if (firstTransientError != null)
        {
            _logger.LogWarning(
                "SMS notification to list '{List}' has a transient failure; the notification will retry. {Detail}",
                listName, firstTransientError);
            return DeliveryOutcome.Transient(firstTransientError);
        }

        // No transient failures. If anything was accepted, do NOT park even when some
        // numbers permanently failed — record the bad numbers and report success.
        if (accepted.Count > 0)
        {
            var targets = string.Join(", ", accepted);
            if (permanentlyFailed.Count > 0)
            {
                var note =
                    $"Delivered to {accepted.Count} recipient(s); " +
                    $"{permanentlyFailed.Count} permanently failed: {string.Join(", ", permanentlyFailed)}";
                _logger.LogWarning(
                    "SMS notification to list '{List}' partially delivered: {Note}", listName, note);
                // ResolvedTargets carries the accepted numbers AND the failed-number note
                // so the audit row preserves the permanently-failed recipients.
                return DeliveryOutcome.Success($"{targets} [{note}]");
            }

            return DeliveryOutcome.Success(targets);
        }

        // Nothing accepted and no transient failures — every recipient permanently
        // failed. Park the notification.
        var error = permanentlyFailed.Count > 0
            ? $"All SMS recipients permanently failed: {string.Join(", ", permanentlyFailed)}"
            : "SMS delivery produced no accepted recipients";
        _logger.LogError(
            "SMS notification to list '{List}' failed permanently: {Error}", listName, error);
        return DeliveryOutcome.Permanent(error);
    }

    /// <summary>
    /// Composes the plain-text SMS body: <c>Subject</c> + newline + <c>Body</c>
    /// (skipping whichever is empty), truncated to <see cref="SmsOptions.MaxMessageLength"/>
    /// with a trailing ellipsis when over the cap. SMS has no subject line.
    /// </summary>
    private string ComposeBody(string? subject, string? body)
    {
        var parts = new List<string>(2);
        if (!string.IsNullOrWhiteSpace(subject))
        {
            parts.Add(subject);
        }

        if (!string.IsNullOrWhiteSpace(body))
        {
            parts.Add(body);
        }

        var composed = string.Join("\n", parts);

        var max = _options.MaxMessageLength;
        if (composed.Length <= max)
        {
            return composed;
        }

        // Truncate to the cap and append an ellipsis (counted inside the cap so the
        // result never exceeds MaxMessageLength). The ellipsis is a single char.
        const string ellipsis = "…";
        if (max <= ellipsis.Length)
        {
            return composed[..max];
        }

        // Cut at (max - ellipsis) UTF-16 code units, backing off by one if that boundary
        // would split a surrogate pair (e.g. an emoji in the alarm subject/body). `cut`
        // is the index of the first dropped char, so a split is signalled by a high
        // surrogate immediately before it; dropping that lone surrogate keeps the body
        // well-formed and still within the cap.
        var cut = max - ellipsis.Length;
        if (char.IsHighSurrogate(composed[cut - 1]))
        {
            cut--;
        }

        return string.Concat(composed.AsSpan(0, cut), ellipsis);
    }

    /// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary>
    /// <param name="Class">
    /// <see cref="SmsErrorClass.Unknown"/> = accepted (HTTP 2xx); otherwise the failure class.
    /// </param>
    /// <param name="Detail">A redacted, human-readable failure description, or null on accept.</param>
    private readonly record struct SmsAttempt(SmsErrorClass Class, string? Detail);
}
