using System.Net;
using System.Text;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.NotificationService.Ews;

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

/// <summary>
/// Tests for the EWS SOAP mail sender: request shape (Basic header, content type, BCC-only
/// envelope) and the transient-versus-permanent classification of every failure shape.
/// <para>
/// Every credential here is fake and every endpoint points at the reserved <c>.test</c> TLD —
/// no test in this file touches a network.
/// </para>
/// </summary>
public class EwsSoapMailSenderTests
{
    private const string Username = @"dom\svc";
    private const string Password = "not-a-real-password-1234";
    private const string Recipient = "operator@example.test";
    private static readonly Uri Endpoint = new("https://ews.example.test/EWS/Exchange.asmx");

    /// <summary>The Basic-auth value the sender is expected to produce for the fake credential.</summary>
    private static readonly string ExpectedBase64 =
        Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Username}:{Password}"));

    private const string SuccessBody = """
        <?xml version="1.0" encoding="utf-8"?>
        <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
          <s:Body>
            <m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages"
                                  xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
              <m:ResponseMessages>
                <m:CreateItemResponseMessage ResponseClass="Success">
                  <m:ResponseCode>NoError</m:ResponseCode>
                  <m:Items><t:Message><t:ItemId Id="AAA=" ChangeKey="CQ=="/></t:Message></m:Items>
                </m:CreateItemResponseMessage>
              </m:ResponseMessages>
            </m:CreateItemResponse>
          </s:Body>
        </s:Envelope>
        """;

    private const string ServerBusyBody = """
        <?xml version="1.0" encoding="utf-8"?>
        <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
          <s:Body>
            <m:CreateItemResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages">
              <m:ResponseMessages>
                <m:CreateItemResponseMessage ResponseClass="Error">
                  <m:MessageText>The server is busy.</m:MessageText>
                  <m:ResponseCode>ErrorServerBusy</m:ResponseCode>
                </m:CreateItemResponseMessage>
              </m:ResponseMessages>
            </m:CreateItemResponse>
          </s:Body>
        </s:Envelope>
        """;

    private const string SchemaValidationFaultBody = """
        <?xml version="1.0" encoding="utf-8"?>
        <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
          <s:Body>
            <s:Fault>
              <faultcode xmlns:a="http://schemas.microsoft.com/exchange/services/2006/types">a:ErrorSchemaValidation</faultcode>
              <faultstring xml:lang="en-US">The request failed schema validation.</faultstring>
              <detail>
                <e:ResponseCode xmlns:e="http://schemas.microsoft.com/exchange/services/2006/errors">ErrorSchemaValidation</e:ResponseCode>
              </detail>
            </s:Fault>
          </s:Body>
        </s:Envelope>
        """;

    [Fact]
    public async Task SendAsync_SuccessResponse_CompletesAndSendsExpectedRequest()
    {
        var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
        var factory = new FakeHttpClientFactory(handler);
        var sender = CreateSender(factory);

        await sender.SendAsync(CreateRequest());

        Assert.Equal(EwsSoapMailSender.HttpClientName, factory.RequestedClientName);
        Assert.Equal("Basic", handler.CapturedScheme);
        Assert.Equal(ExpectedBase64, handler.CapturedParameter);
        Assert.Equal("text/xml", handler.CapturedMediaType);
        Assert.Contains(Recipient, handler.CapturedContent);
    }

    [Fact]
    public async Task SendAsync_NonHttpsEndpoint_ThrowsPermanentWithoutSendingCredentials()
    {
        // A Basic header is a replayable cleartext credential: the guard must fire before the
        // request is ever built, so a misconfigured http:// URL cannot put it on the wire.
        var handler = StubHandler.Responding(HttpStatusCode.OK, SuccessBody);
        var sender = CreateSender(handler);
        var request = CreateRequest() with { Endpoint = new Uri("http://ews.example.test/EWS/Exchange.asmx") };

        var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(request));

        Assert.Equal(0, handler.InvocationCount);
        Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_ServerBusyResponseBody_ThrowsTransient()
    {
        var handler = StubHandler.Responding(HttpStatusCode.OK, ServerBusyBody);
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));

        Assert.Contains("ErrorServerBusy", ex.Message);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_Unauthorized_ThrowsPermanent()
    {
        var handler = StubHandler.Responding(HttpStatusCode.Unauthorized, string.Empty);
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));

        Assert.Contains("401", ex.Message);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_SchemaFaultOnHttp500_ThrowsPermanent()
    {
        // The parsed fault beats the HTTP status: a 500 would otherwise look transient, but
        // a schema-invalid request is never fixed by retrying it unchanged.
        var handler = StubHandler.Responding(HttpStatusCode.InternalServerError, SchemaValidationFaultBody);
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));

        Assert.Contains("ErrorSchemaValidation", ex.Message);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_BareServiceUnavailable_ThrowsTransient()
    {
        var handler = StubHandler.Responding(HttpStatusCode.ServiceUnavailable, "<html>proxy down</html>");
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));

        Assert.Contains("503", ex.Message);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_TransportFailure_ThrowsTransient()
    {
        var handler = StubHandler.Throwing(new HttpRequestException("No such host is known."));
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsTransientException>(() => sender.SendAsync(CreateRequest()));

        Assert.IsType<HttpRequestException>(ex.InnerException);
        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_RequestExceedsTimeout_ThrowsTransientNotCancellation()
    {
        var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsTransientException>(
            () => sender.SendAsync(CreateRequest(timeoutSeconds: 1)));

        AssertNoCredentialLeak(ex);
    }

    [Fact]
    public async Task SendAsync_CallerCancelled_PropagatesCancellationUnwrapped()
    {
        var handler = StubHandler.Delaying(TimeSpan.FromSeconds(30));
        var sender = CreateSender(handler);
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();

        var ex = await Assert.ThrowsAnyAsync<OperationCanceledException>(
            () => sender.SendAsync(CreateRequest(timeoutSeconds: 30), cts.Token));

        Assert.IsNotType<EwsTransientException>(ex);
        Assert.IsNotType<EwsPermanentException>(ex);
    }

    [Fact]
    public async Task SendAsync_SuccessStatusWithUnparseableBody_ThrowsPermanent()
    {
        // A 200 whose body is not an EWS response is a protocol violation from something in
        // the path (captive portal / proxy error page); replaying it changes nothing.
        var handler = StubHandler.Responding(HttpStatusCode.OK, "not xml at all");
        var sender = CreateSender(handler);

        var ex = await Assert.ThrowsAsync<EwsPermanentException>(() => sender.SendAsync(CreateRequest()));

        AssertNoCredentialLeak(ex);
    }

    /// <summary>
    /// Neither the password nor the base64 Basic-auth value may reach an exception — those land
    /// in the operational log and in the notification's stored error. The whole
    /// <see cref="Exception.ToString"/> is scanned, so an inner transport exception carrying the
    /// credential would fail this too.
    /// </summary>
    private static void AssertNoCredentialLeak(Exception ex)
    {
        var rendered = ex.ToString();
        Assert.DoesNotContain(Password, rendered, StringComparison.Ordinal);
        Assert.DoesNotContain(ExpectedBase64, rendered, StringComparison.Ordinal);
    }

    private static EwsSendRequest CreateRequest(int timeoutSeconds = 0)
        => new(
            Endpoint,
            Username,
            Password,
            "alerts@example.test",
            new[] { Recipient },
            "Alarm raised",
            "Tank 4 level high.",
            timeoutSeconds);

    private static EwsSoapMailSender CreateSender(HttpMessageHandler handler)
        => CreateSender(new FakeHttpClientFactory(handler));

    private static EwsSoapMailSender CreateSender(FakeHttpClientFactory factory)
        => new(factory, NullLogger<EwsSoapMailSender>.Instance);

    /// <summary>Hands every named client the one stub handler under test.</summary>
    private sealed class FakeHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
    {
        /// <summary>The client name the sender asked for, so tests can pin the named registration.</summary>
        public string? RequestedClientName { get; private set; }

        public HttpClient CreateClient(string name)
        {
            RequestedClientName = name;
            return new HttpClient(handler, disposeHandler: false);
        }
    }

    /// <summary>
    /// Captures the outgoing request (auth header, content type, serialized body) and answers
    /// with a canned response, a thrown exception, or a delay long enough to trip a timeout.
    /// </summary>
    private sealed class StubHandler : HttpMessageHandler
    {
        private readonly Func<CancellationToken, Task<HttpResponseMessage>> _responder;

        private StubHandler(Func<CancellationToken, Task<HttpResponseMessage>> responder)
            => _responder = responder;

        public string? CapturedScheme { get; private set; }

        public string? CapturedParameter { get; private set; }

        public string? CapturedMediaType { get; private set; }

        public string CapturedContent { get; private set; } = string.Empty;

        /// <summary>How many times the handler was reached — 0 proves a guard fired first.</summary>
        public int InvocationCount { get; private set; }

        public static StubHandler Responding(HttpStatusCode status, string body)
            => new(_ => Task.FromResult(new HttpResponseMessage(status)
            {
                Content = new StringContent(body, Encoding.UTF8, "text/xml"),
            }));

        public static StubHandler Throwing(Exception exception)
            => new(_ => Task.FromException<HttpResponseMessage>(exception));

        public static StubHandler Delaying(TimeSpan delay)
            => new(async token =>
            {
                await Task.Delay(delay, token);
                return new HttpResponseMessage(HttpStatusCode.OK);
            });

        protected override async Task<HttpResponseMessage> SendAsync(
            HttpRequestMessage request, CancellationToken cancellationToken)
        {
            InvocationCount++;
            CapturedScheme = request.Headers.Authorization?.Scheme;
            CapturedParameter = request.Headers.Authorization?.Parameter;
            CapturedMediaType = request.Content?.Headers.ContentType?.MediaType;
            CapturedContent = request.Content is null
                ? string.Empty
                : await request.Content.ReadAsStringAsync(cancellationToken);

            return await _responder(cancellationToken);
        }
    }
}
