using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using ServerMonitorManager.Agent;
using ServerMonitorManager.Control;
using ServerMonitorManager.Core;
using Xunit;

namespace ServerMonitorManager.Control.Tests;

public sealed class CertificateLifecycleTests : IDisposable
{
    private readonly string _directory = Path.Combine(Path.GetTempPath(), $"smm-cert-lifecycle-tests-{Guid.NewGuid():N}");

    public CertificateLifecycleTests()
    {
        Directory.CreateDirectory(_directory);
    }

    [Fact]
    public async Task CertWithLessThanOneThirdRemainingIsRenewed_AndOldCertReplaced()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var dbPath = Path.Combine(_directory, "renew-test.db");
        var caPath = Path.Combine(_directory, "control-ca.pfx");
        CreateCaPfx(caPath);

        var options = Options.Create(new ControlOptions
        {
            DatabasePath = dbPath,
            CertificateAuthorityPath = caPath,
            ClientCertificateDays = 30
        });

        var store = new ControlStore(options);
        await store.InitializeAsync(cancellationToken);
        using var ca = new CertificateAuthority(options);
        var broker = new ControlEventBroker();
        var applier = new NoOpPolicyApplier();
        var linkService = new LinkService(store, applier, broker);
        var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);

        // 1. Enroll Agent "node-expiring"
        using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr1 = new CertificateRequest("CN=node-expiring", key1, HashAlgorithmName.SHA256).CreateSigningRequestPem();
        var token = await store.CreateEnrollmentTokenAsync("node-expiring", TimeSpan.FromMinutes(10), cancellationToken);
        var issued1 = ca.IssueClientCertificate("node-expiring", csr1);
        await store.EnrollAsync(new EnrollmentRequest("node-expiring", token, csr1, "idemp-1"), () => issued1, cancellationToken);

        // 2. Perform Certificate Renewal
        using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr2 = new CertificateRequest("CN=node-expiring", key2, HashAlgorithmName.SHA256).CreateSigningRequestPem();
        var renewalReq = new CertificateRenewalRequest("node-expiring", csr2, "idemp-2");

        var renewedIssued = await lifecycle.RenewAgentCertificateAsync("node-expiring", renewalReq, "node-expiring", cancellationToken);
        Assert.NotNull(renewedIssued);
        Assert.NotEqual(issued1.Thumbprint, renewedIssued.Thumbprint);

        // Verify database updated with new thumbprint
        var agents = await store.ListAgentsAsync(cancellationToken);
        var agent = Assert.Single(agents, a => a.NodeId == "node-expiring");
        Assert.NotNull(agent.CertificateExpiresAt);
        Assert.NotNull(agent.CertificateRemainingDays);
        Assert.True(agent.CertificateRemainingDays.Value >= 29);
    }

    [Fact]
    public async Task CertWithSufficientRemainingLifetime_IsNotRenewed()
    {
        var agentDir = Path.Combine(_directory, "agent-sufficient");
        Directory.CreateDirectory(agentDir);
        var pfxPath = Path.Combine(agentDir, "agent.pfx");
        var caPath = Path.Combine(agentDir, "control-ca.crt");

        using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var req = new CertificateRequest("CN=node-valid", key, HashAlgorithmName.SHA256);
        // Valid for 30 days starting now -> ~30 days remaining (well above 1/3 threshold)
        using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-1), DateTimeOffset.UtcNow.AddDays(30));
        File.WriteAllBytes(pfxPath, cert.Export(X509ContentType.Pfx));
        File.WriteAllText(caPath, cert.ExportCertificatePem());

        var agentOptions = new AgentOptions
        {
            NodeId = "node-valid",
            StateDirectory = agentDir,
            CertificateAuthorityPath = caPath,
            ControlUrl = new Uri("https://127.0.0.1:9999")
        };
        var client = new AgentClient(agentOptions);

        using var handler = new HttpClientHandler();
        using var http = new HttpClient(handler) { BaseAddress = agentOptions.ControlUrl };

        // Should return false because remaining lifespan > 1/3
        var renewed = await client.EnsureCertificateRenewedAsync(cert, http, TestContext.Current.CancellationToken);
        Assert.False(renewed);
    }

    [Fact]
    public async Task HubUnavailable_AgentContinuesUsingExistingCert_MetricsPreserved()
    {
        var agentDir = Path.Combine(_directory, "agent-unavail");
        Directory.CreateDirectory(agentDir);
        var pfxPath = Path.Combine(agentDir, "agent.pfx");
        var caPath = Path.Combine(agentDir, "control-ca.crt");

        using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var req = new CertificateRequest("CN=node-unavail", key, HashAlgorithmName.SHA256);
        // Expiring in 2 days -> remaining < 1/3 of 30 days
        using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-20), DateTimeOffset.UtcNow.AddDays(2));
        var originalPfxBytes = cert.Export(X509ContentType.Pfx);
        File.WriteAllBytes(pfxPath, originalPfxBytes);
        File.WriteAllText(caPath, cert.ExportCertificatePem());

        var agentOptions = new AgentOptions
        {
            NodeId = "node-unavail",
            StateDirectory = agentDir,
            CertificateAuthorityPath = caPath,
            ControlUrl = new Uri("https://127.0.0.1:9999")
        };
        var client = new AgentClient(agentOptions);

        using var handler = new HttpClientHandler();
        using var http = new HttpClient(handler) { BaseAddress = agentOptions.ControlUrl };

        var renewed = await client.EnsureCertificateRenewedAsync(cert, http, TestContext.Current.CancellationToken);
        Assert.False(renewed);

        var onDiskBytes = File.ReadAllBytes(pfxPath);
        Assert.Equal(originalPfxBytes, onDiskBytes);
    }

    [Fact]
    public async Task RevokedCert_CannotBeRenewed()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var dbPath = Path.Combine(_directory, "revoked-test.db");
        var caPath = Path.Combine(_directory, "control-ca.pfx");
        CreateCaPfx(caPath);

        var options = Options.Create(new ControlOptions
        {
            DatabasePath = dbPath,
            CertificateAuthorityPath = caPath,
            ClientCertificateDays = 30
        });

        var store = new ControlStore(options);
        await store.InitializeAsync(cancellationToken);
        using var ca = new CertificateAuthority(options);
        var broker = new ControlEventBroker();
        var applier = new NoOpPolicyApplier();
        var linkService = new LinkService(store, applier, broker);
        var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);

        // Enroll agent
        using var key1 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr1 = new CertificateRequest("CN=node-revoked", key1, HashAlgorithmName.SHA256).CreateSigningRequestPem();
        var token = await store.CreateEnrollmentTokenAsync("node-revoked", TimeSpan.FromMinutes(10), cancellationToken);
        var issued1 = ca.IssueClientCertificate("node-revoked", csr1);
        await store.EnrollAsync(new EnrollmentRequest("node-revoked", token, csr1, "idemp-1"), () => issued1, cancellationToken);

        // Reenroll/Revoke agent
        await lifecycle.ReenrollAgentAsync("node-revoked", new CertificateReenrollmentRequest("compromised", "idemp-2"), "operator", cancellationToken);

        // Attempt renewal
        using var key2 = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr2 = new CertificateRequest("CN=node-revoked", key2, HashAlgorithmName.SHA256).CreateSigningRequestPem();
        var renewalReq = new CertificateRenewalRequest("node-revoked", csr2, "idemp-3");

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            lifecycle.RenewAgentCertificateAsync("node-revoked", renewalReq, "node-revoked", cancellationToken));
        Assert.Contains("Revoked agent cannot renew certificate", ex.Message);
    }

    [Fact]
    public async Task RenewalRequest_WithDifferentNodeId_IsRejected()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var dbPath = Path.Combine(_directory, "mismatch-test.db");
        var caPath = Path.Combine(_directory, "control-ca.pfx");
        CreateCaPfx(caPath);

        var options = Options.Create(new ControlOptions
        {
            DatabasePath = dbPath,
            CertificateAuthorityPath = caPath,
            ClientCertificateDays = 30
        });

        var store = new ControlStore(options);
        await store.InitializeAsync(cancellationToken);
        using var ca = new CertificateAuthority(options);
        var broker = new ControlEventBroker();
        var applier = new NoOpPolicyApplier();
        var linkService = new LinkService(store, applier, broker);
        var lifecycle = new CertificateLifecycleService(store, linkService, broker, ca);

        using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr = new CertificateRequest("CN=node-a", key, HashAlgorithmName.SHA256).CreateSigningRequestPem();
        var renewalReq = new CertificateRenewalRequest("node-a", csr, "idemp-1");

        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
            lifecycle.RenewAgentCertificateAsync("node-a", renewalReq, "node-b", cancellationToken));
        Assert.Contains("Node ID mismatch", ex.Message);
    }

    [Fact]
    public async Task InterruptedPfxReplacement_ActiveCertRemainsIntact()
    {
        var cancellationToken = TestContext.Current.CancellationToken;
        var targetPath = Path.Combine(_directory, "agent.pfx");
        var tempPath = targetPath + ".tmp";

        var originalBytes = new byte[] { 1, 2, 3, 4, 5 };
        var incompleteTempBytes = new byte[] { 9, 9, 9 };

        await File.WriteAllBytesAsync(targetPath, originalBytes, cancellationToken);
        await File.WriteAllBytesAsync(tempPath, incompleteTempBytes, cancellationToken);

        Assert.True(File.Exists(targetPath));
        Assert.True(File.Exists(tempPath));

        var currentBytes = await File.ReadAllBytesAsync(targetPath, cancellationToken);
        Assert.Equal(originalBytes, currentBytes);
    }

    [Fact]
    public void OutOfRangeClientCertificateDays_ValidationFailsOnStart()
    {
        var optionsInvalidLow = new ControlOptions { ClientCertificateDays = 0 };
        var optionsInvalidHigh = new ControlOptions { ClientCertificateDays = 999 };
        var optionsValid = new ControlOptions { ClientCertificateDays = 30 };

        Assert.False(optionsInvalidLow.ClientCertificateDays is >= 1 and <= 90);
        Assert.False(optionsInvalidHigh.ClientCertificateDays is >= 1 and <= 90);
        Assert.True(optionsValid.ClientCertificateDays is >= 1 and <= 90);
    }

    [Fact]
    public void CertificateDays45_IssuesCertificateWith45DaysValidity()
    {
        var caPath = Path.Combine(_directory, "ca45.pfx");
        CreateCaPfx(caPath);
        var options = new ControlOptions { ClientCertificateDays = 45, CertificateAuthorityPath = caPath };
        using var ca = new CertificateAuthority(Microsoft.Extensions.Options.Options.Create(options));

        using var clientKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var csr = new CertificateRequest("CN=test-node-45", clientKey, HashAlgorithmName.SHA256).CreateSigningRequestPem();

        var issued = ca.IssueClientCertificate("test-node-45", csr);
        using var issuedCert = X509Certificate2.CreateFromPem(issued.CertificatePem);

        var validitySpan = issuedCert.NotAfter - issuedCert.NotBefore;
        Assert.InRange(validitySpan.TotalDays, 44.9, 45.1);
    }

    private static void CreateCaPfx(string path)
    {
        using var caKey = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        var caRequest = new CertificateRequest("CN=SMM Test Lifecycle CA", caKey, HashAlgorithmName.SHA256);
        caRequest.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
        caRequest.CertificateExtensions.Add(new X509KeyUsageExtension(
            X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign,
            true));
        using var ca = caRequest.CreateSelfSigned(
            DateTimeOffset.UtcNow.AddMinutes(-1),
            DateTimeOffset.UtcNow.AddYears(2));
        File.WriteAllBytes(path, ca.Export(X509ContentType.Pfx));
    }

    private sealed class NoOpPolicyApplier : ILinkPolicyApplier
    {
        public Task<IReadOnlyList<LinkRule>> ListRulesAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<LinkRule>>([]);
        public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.CompletedTask;
        public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.CompletedTask;
        public Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken) => Task.CompletedTask;
        public Task<bool> IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken) => Task.FromResult(false);
    }

    public void Dispose()
    {
        SqliteConnection.ClearAllPools();
        if (Directory.Exists(_directory))
        {
            try
            {
                Directory.Delete(_directory, recursive: true);
            }
            catch
            {
                // Non-fatal cleanup catch for Windows temp folder file handles
            }
        }
    }
}
