using System.Net;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Tls;
using Xunit;

namespace ZB.MOM.WW.MxGateway.Tests.Security.Tls;

public sealed class SelfSignedCertificateProviderTests
{
    private static SelfSignedCertificateProvider CreateProvider(TlsOptions options, FakeTimeProvider time)
        => new(options, NullLogger<SelfSignedCertificateProvider>.Instance, time);

    /// <summary>Verifies that a generated certificate has the expected validity window, SANs (localhost, machine name, additional DNS names, loopback IPs), and the serverAuth EKU.</summary>
    [Fact]
    public void GenerateCertificate_HasExpectedSansEkuAndValidity()
    {
        FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
        TlsOptions options = new() { ValidityYears = 7, AdditionalDnsNames = ["gw.internal"] };

        using X509Certificate2 cert = CreateProvider(options, time).GenerateCertificate();

        Assert.Equal(time.GetUtcNow().AddYears(7).UtcDateTime.Date, cert.NotAfter.ToUniversalTime().Date);
        Assert.True(cert.NotBefore.ToUniversalTime() < time.GetUtcNow().UtcDateTime);
        Assert.True(cert.HasPrivateKey);

        X509SubjectAlternativeNameExtension san = ReadSubjectAltNames(cert);
        string[] dnsNames = [.. san.EnumerateDnsNames()];
        IPAddress[] ipAddresses = [.. san.EnumerateIPAddresses()];

        // DNS SANs are compared case-insensitively (DNS names are), and IP SANs are compared
        // as parsed IPAddress values. Asserting against the extension's Format() string instead
        // would be platform-dependent: Windows' CryptFormatObject renders the IPv6 loopback
        // fully expanded ("0000:0000:...:0001") while the managed formatter renders "::1".
        Assert.Contains(dnsNames, name => name.Equals("localhost", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(dnsNames, name => name.Equals("gw.internal", StringComparison.OrdinalIgnoreCase));
        Assert.Contains(dnsNames, name => name.Equals(Environment.MachineName, StringComparison.OrdinalIgnoreCase));
        Assert.Contains(IPAddress.Loopback, ipAddresses);
        Assert.Contains(IPAddress.IPv6Loopback, ipAddresses);

        X509EnhancedKeyUsageExtension eku = cert.Extensions.OfType<X509EnhancedKeyUsageExtension>().Single();
        Assert.Contains(eku.EnhancedKeyUsages.Cast<System.Security.Cryptography.Oid>(),
            o => o.Value == "1.3.6.1.5.5.7.3.1"); // serverAuth
    }

    /// <summary>Verifies that LoadOrCreate generates and persists a certificate on first call, then reuses the same persisted certificate (same thumbprint) on a subsequent call.</summary>
    [Fact]
    public void LoadOrCreate_GeneratesPersistsAndReuses_SameThumbprint()
    {
        string dir = Directory.CreateTempSubdirectory().FullName;
        try
        {
            string path = Path.Combine(dir, "gw.pfx");
            FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
            TlsOptions options = new() { SelfSignedCertPath = path };

            using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
            Assert.True(File.Exists(path));
            using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();

            Assert.Equal(first.Thumbprint, second.Thumbprint); // reused, not regenerated
        }
        finally { Directory.Delete(dir, recursive: true); }
    }

    /// <summary>Verifies that LoadOrCreate regenerates the certificate (a new thumbprint) once the persisted certificate's validity window has elapsed.</summary>
    [Fact]
    public void LoadOrCreate_Regenerates_WhenPersistedCertExpired()
    {
        string dir = Directory.CreateTempSubdirectory().FullName;
        try
        {
            string path = Path.Combine(dir, "gw.pfx");
            FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
            TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1 };

            using X509Certificate2 first = CreateProvider(options, time).LoadOrCreate();
            time.Advance(TimeSpan.FromDays(800)); // past 1-year validity
            using X509Certificate2 second = CreateProvider(options, time).LoadOrCreate();

            Assert.NotEqual(first.Thumbprint, second.Thumbprint);
        }
        finally { Directory.Delete(dir, recursive: true); }
    }

    /// <summary>Verifies that LoadOrCreate regenerates a valid certificate when the persisted PFX file is corrupt or unreadable.</summary>
    [Fact]
    public void LoadOrCreate_Regenerates_WhenPersistedFileCorrupt()
    {
        string dir = Directory.CreateTempSubdirectory().FullName;
        try
        {
            string path = Path.Combine(dir, "gw.pfx");
            File.WriteAllText(path, "not a pfx");
            TlsOptions options = new() { SelfSignedCertPath = path };
            using X509Certificate2 cert = CreateProvider(options, new FakeTimeProvider()).LoadOrCreate();
            Assert.True(cert.HasPrivateKey);
        }
        finally { Directory.Delete(dir, recursive: true); }
    }

    /// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> for an expired persisted certificate when regeneration is disabled.</summary>
    [Fact]
    public void LoadOrCreate_Throws_WhenExpiredAndRegenerateDisabled()
    {
        string dir = Directory.CreateTempSubdirectory().FullName;
        try
        {
            string path = Path.Combine(dir, "gw.pfx");
            FakeTimeProvider time = new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero));
            TlsOptions options = new() { SelfSignedCertPath = path, ValidityYears = 1, RegenerateIfExpired = false };
            using (CreateProvider(options, time).LoadOrCreate()) { }
            time.Advance(TimeSpan.FromDays(800));
            Assert.Throws<InvalidOperationException>(() => CreateProvider(options, time).LoadOrCreate());
        }
        finally { Directory.Delete(dir, recursive: true); }
    }

    /// <summary>Verifies that LoadOrCreate throws <see cref="InvalidOperationException"/> when <c>SelfSignedCertPath</c> is blank.</summary>
    [Fact]
    public void LoadOrCreate_Throws_WhenSelfSignedCertPathBlank()
    {
        TlsOptions options = new() { SelfSignedCertPath = "  " };
        Assert.Throws<InvalidOperationException>(
            () => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());
    }

    /// <summary>
    /// Verifies that GenerateAndPersist cleans up the hardened .tmp file when persist fails.
    /// The failure is induced by setting SelfSignedCertPath to a path whose parent directory
    /// is an existing regular file, causing Directory.CreateDirectory (or the subsequent write)
    /// to throw an IOException/UnauthorizedAccessException.
    /// </summary>
    [Fact]
    public void LoadOrCreate_DeletesTempFile_WhenPersistFails()
    {
        string outerDir = Directory.CreateTempSubdirectory().FullName;
        try
        {
            // Create a regular file at what would be the parent directory of the cert path.
            // Any attempt to create that "directory" or write files into it must fail.
            string fileActingAsDir = Path.Combine(outerDir, "notadir");
            File.WriteAllText(fileActingAsDir, "block");

            // Point the cert path inside the regular file — Directory.CreateDirectory will
            // throw because the parent path component is a file, not a directory.
            string certPath = Path.Combine(fileActingAsDir, "gw.pfx");
            string expectedTemp = certPath + ".tmp";

            TlsOptions options = new() { SelfSignedCertPath = certPath };
            Assert.ThrowsAny<Exception>(() => CreateProvider(options, new FakeTimeProvider()).LoadOrCreate());

            // The .tmp file must not be left behind.
            Assert.False(File.Exists(expectedTemp), $"Leaked temp file: {expectedTemp}");
        }
        finally { Directory.Delete(outerDir, recursive: true); }
    }

    private const string SubjectAltNameOid = "2.5.29.17";

    private static X509SubjectAlternativeNameExtension ReadSubjectAltNames(X509Certificate2 cert)
        => new(cert.Extensions.First(e => e.Oid?.Value == SubjectAltNameOid).RawData);
}
