using System.Net;
using System.Net.Http.Headers;
using System.Text;
using Microsoft.Extensions.Logging;

namespace ZB.MOM.WW.ScadaBridge.NotificationService.Ews;

/// <summary>
/// Sends notification mail through Exchange Web Services over plain HTTPS + SOAP — no EWS SDK,
/// honouring the project's no-new-NuGet-package rule.
/// <para>
/// Authentication is an explicit <c>Authorization: Basic</c> header set on each
/// <see cref="HttpRequestMessage"/>, never on the shared <see cref="HttpClient"/>: the factory's
/// handler is pooled and shared across every configuration, so a client-level credential would
/// leak between callers. Negotiate/NTLM is the documented follow-on if Basic is ever disabled on
/// the EWS virtual directory.
/// </para>
/// <para>
/// Classification (see the design's §4.3) prefers what Exchange <em>said</em> over what HTTP
/// reported: a parsed response code decides the outcome even on a 500, and only an unparseable
/// body falls back to the status code. Unclassified failures default to permanent, matching the
/// SMTP adapter's stance.
/// </para>
/// <para>
/// The password and the base64 Basic-auth value never appear in a thrown message or a log line:
/// attempts are logged at Debug with the endpoint host and a recipient <em>count</em> only, and
/// every surfaced message runs through <see cref="CredentialRedactor"/>.
/// </para>
/// </summary>
public sealed class EwsSoapMailSender : IEwsMailSender
{
    /// <summary>The named <see cref="HttpClient"/> registered for EWS in <c>ServiceCollectionExtensions</c>.</summary>
    public const string HttpClientName = "EwsMail";

    /// <summary>Mask applied to the base64 credential, matching <see cref="CredentialRedactor"/>'s.</summary>
    private const string Mask = "***REDACTED***";

    /// <summary>
    /// EWS response codes that describe load or availability rather than a defect in the request.
    /// Everything else — schema faults, recipient rejections, authorization — is permanent.
    /// </summary>
    private static readonly HashSet<string> TransientResponseCodes = new(StringComparer.OrdinalIgnoreCase)
    {
        "ErrorServerBusy",
        "ErrorInternalServerTransientError",
        "ErrorTimeoutExpired",
        "ErrorMailboxStoreUnavailable",
        "ErrorInsufficientResources",
    };

    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger<EwsSoapMailSender> _logger;

    /// <summary>Initializes a new instance of <see cref="EwsSoapMailSender"/>.</summary>
    /// <param name="httpClientFactory">Factory creating the named <c>"EwsMail"</c> HTTP client per send.</param>
    /// <param name="logger">Logger instance.</param>
    public EwsSoapMailSender(IHttpClientFactory httpClientFactory, ILogger<EwsSoapMailSender> logger)
    {
        ArgumentNullException.ThrowIfNull(httpClientFactory);
        ArgumentNullException.ThrowIfNull(logger);

        _httpClientFactory = httpClientFactory;
        _logger = logger;
    }

    /// <inheritdoc />
    public async Task SendAsync(EwsSendRequest request, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(request);

        // Defense in depth: a Basic header is a replayable cleartext credential, so it never
        // leaves this process over anything but https — not even if a misconfigured
        // SmtpConfiguration row supplies an http:// URL. Config defects do not improve on
        // retry, so this is permanent. Only the scheme and host appear in the message.
        if (!string.Equals(request.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
        {
            var schemeError =
                $"EWS endpoint '{request.Endpoint.Scheme}://{request.Endpoint.Host}' is not https; " +
                "Basic credentials are only ever sent over https.";
            _logger.LogError("Permanent EWS failure: {Detail}", schemeError);
            throw new EwsPermanentException(schemeError);
        }

        var envelope = EwsSoapEnvelope.BuildCreateItem(
            request.FromAddress, request.BccRecipients, request.Subject, request.Body);

        var packedCredential = $"{request.Username}:{request.Password}";
        var base64Credential = Convert.ToBase64String(Encoding.UTF8.GetBytes(packedCredential));

        // Recipient COUNT only — addresses are notification content and do not belong in the log.
        _logger.LogDebug(
            "Submitting EWS CreateItem to {EwsHost} for {RecipientCount} recipient(s).",
            request.Endpoint.Host,
            request.BccRecipients.Count);

        // Per-request timeout layered over the caller's token, so a stalled Exchange surfaces as
        // a TaskCanceledException with no caller cancel — classified transient below — while a
        // genuine caller cancel still propagates unwrapped. HttpClient.Timeout is left alone: the
        // client is shared and its timeout is not per-configuration.
        using var linkedCts = request.TimeoutSeconds > 0
            ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
            : null;
        linkedCts?.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds));
        var sendToken = linkedCts?.Token ?? cancellationToken;

        var httpClient = _httpClientFactory.CreateClient(HttpClientName);

        try
        {
            using var httpRequest = new HttpRequestMessage(HttpMethod.Post, request.Endpoint)
            {
                Content = new StringContent(envelope, Encoding.UTF8, "text/xml"),
            };
            httpRequest.Headers.Authorization =
                new AuthenticationHeaderValue("Basic", base64Credential);

            using var response = await httpClient.SendAsync(httpRequest, sendToken);
            var responseBody = await response.Content.ReadAsStringAsync(sendToken);

            ThrowOnFailure(response.StatusCode, responseBody, packedCredential, base64Credential);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The caller cancelled: neither a success nor a delivery failure, so it propagates
            // unchanged rather than being recorded as a transient send error.
            throw;
        }
        catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
        {
            // Transport failure or our own timeout — availability-shaped, so retry. Logged with
            // the host and exception type only: the exception message is untrusted text and the
            // recipient addresses are notification content.
            _logger.LogWarning(
                "Transient EWS failure contacting {EwsHost} ({ExceptionType}).",
                request.Endpoint.Host,
                ex.GetType().Name);

            throw new EwsTransientException(
                Scrub(
                    $"EWS request to {request.Endpoint.Host} failed: {ex.Message}",
                    packedCredential,
                    base64Credential),
                ex);
        }
    }

    /// <summary>
    /// Classifies one EWS response, returning quietly on success and throwing the matching typed
    /// exception otherwise. The parsed response body wins over the HTTP status whenever it is
    /// intelligible; only an unparseable body falls back to the status code.
    /// </summary>
    private void ThrowOnFailure(
        HttpStatusCode statusCode,
        string responseBody,
        string packedCredential,
        string base64Credential)
    {
        var parsed = EwsResponseParser.Parse(responseBody);

        // Exchange accepted the message. A non-2xx status alongside a Success response class is
        // not a thing Exchange does, but if a proxy rewrites the status the message still went.
        if (parsed.Kind == EwsResponseKind.Success
            || string.Equals(parsed.ResponseCode, "NoError", StringComparison.OrdinalIgnoreCase))
        {
            return;
        }

        var status = (int)statusCode;

        if (parsed.Kind is EwsResponseKind.Error or EwsResponseKind.Fault)
        {
            var code = parsed.ResponseCode ?? "(no response code)";
            var detail = Scrub(
                $"EWS returned {code} (HTTP {status}): {parsed.MessageText ?? "(no message text)"}",
                packedCredential,
                base64Credential);

            if (parsed.ResponseCode is not null && TransientResponseCodes.Contains(parsed.ResponseCode))
            {
                _logger.LogWarning("Transient EWS failure: {Detail}", detail);
                throw new EwsTransientException(detail);
            }

            // Includes a fault carrying no response code: the default-permanent stance keeps an
            // unrecognised failure from retrying forever.
            _logger.LogError("Permanent EWS failure: {Detail}", detail);
            throw new EwsPermanentException(detail);
        }

        // Unparseable body — nothing but the HTTP status is left to judge on.
        var message = $"EWS endpoint returned {status} ({statusCode}) with an unrecognised body";

        // 401/403 credential or authorization, 404/410 wrong URL, 405 wrong verb/endpoint: all
        // configuration defects, and retrying 401 burns a domain account's lockout budget.
        if (status is 401 or 403 or 404 or 405 or 410)
        {
            _logger.LogError("Permanent EWS failure: {Detail}", message);
            throw new EwsPermanentException(message);
        }

        if (status is 408 or 429 || status >= 500)
        {
            _logger.LogWarning("Transient EWS failure: {Detail}", message);
            throw new EwsTransientException(message);
        }

        // A 2xx whose body is not an EWS response is a protocol violation from something in the
        // path (proxy error page, captive portal); anything else non-success is unclassified.
        // Neither is fixed by replaying the same request.
        _logger.LogError("Permanent EWS failure: {Detail}", message);
        throw new EwsPermanentException(message);
    }

    /// <summary>
    /// Masks both credential shapes — the packed <c>user:password</c> (via the shared
    /// <see cref="CredentialRedactor"/>, which also covers the bare password) and the base64
    /// Basic-auth value — out of text bound for an exception message or a log line.
    /// </summary>
    private static string Scrub(string text, string packedCredential, string base64Credential)
        => CredentialRedactor
            .Scrub(text, packedCredential)
            .Replace(base64Credential, Mask, StringComparison.Ordinal);
}
