using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Server.Configuration;

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

/// <summary>
/// Generates and persists a long-lived self-signed certificate used as the
/// Kestrel HTTPS default when no operator certificate is configured.
/// </summary>
public sealed class SelfSignedCertificateProvider
{
    private const string ServerAuthOid = "1.3.6.1.5.5.7.3.1";
    private const string SubjectAltNameOid = "2.5.29.17";

    private readonly TlsOptions _options;
    private readonly ILogger<SelfSignedCertificateProvider> _logger;
    private readonly TimeProvider _timeProvider;

    /// <summary>Initializes a new instance of the <see cref="SelfSignedCertificateProvider"/> class.</summary>
    /// <param name="options">TLS options controlling certificate generation and persistence.</param>
    /// <param name="logger">Logger for certificate generation and load diagnostics.</param>
    /// <param name="timeProvider">Time provider used for certificate validity and expiration checks.</param>
    public SelfSignedCertificateProvider(
        TlsOptions options,
        ILogger<SelfSignedCertificateProvider> logger,
        TimeProvider timeProvider)
    {
        _options = options;
        _logger = logger;
        _timeProvider = timeProvider;
    }

    /// <summary>Creates a fresh in-memory ECDSA P-256 self-signed certificate.</summary>
    /// <returns>The generated self-signed certificate.</returns>
    public X509Certificate2 GenerateCertificate()
    {
        using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        CertificateRequest request = new(
            new X500DistinguishedName("CN=MxAccessGateway Self-Signed"),
            key,
            HashAlgorithmName.SHA256);

        // End-entity (non-CA) certificate: RFC 5280 marks BasicConstraints critical only for CA certs.
        request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false));
        request.CertificateExtensions.Add(new X509KeyUsageExtension(
            X509KeyUsageFlags.DigitalSignature,
            critical: true));
        request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
            [new Oid(ServerAuthOid, "Server Authentication")],
            critical: false));

        SubjectAlternativeNameBuilder san = new();
        san.AddDnsName("localhost");
        string machine = Environment.MachineName;
        if (!string.IsNullOrWhiteSpace(machine))
        {
            san.AddDnsName(machine);
        }

        // Best-effort: add the machine FQDN when it differs from the short name and "localhost".
        // GetHostEntry may fail if DNS is unavailable; skip silently in that case.
        try
        {
            string fqdn = Dns.GetHostEntry(machine).HostName;
            if (!string.IsNullOrWhiteSpace(fqdn)
                && !fqdn.Equals("localhost", StringComparison.OrdinalIgnoreCase)
                && !fqdn.Equals(machine, StringComparison.OrdinalIgnoreCase))
            {
                san.AddDnsName(fqdn);
            }
        }
        catch (SocketException) { /* DNS not resolvable — FQDN SAN is optional */ }
        catch (ArgumentException) { /* invalid host name — skip */ }

        foreach (string extra in _options.AdditionalDnsNames)
        {
            if (!string.IsNullOrWhiteSpace(extra))
            {
                san.AddDnsName(extra);
            }
        }

        san.AddIpAddress(IPAddress.Loopback);
        san.AddIpAddress(IPAddress.IPv6Loopback);
        request.CertificateExtensions.Add(san.Build());

        DateTimeOffset now = _timeProvider.GetUtcNow();
        return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(_options.ValidityYears));
    }

    /// <summary>Loads the persisted certificate, regenerating when missing,
    /// expired (and allowed), or unreadable.</summary>
    /// <returns>The loaded or newly generated and persisted certificate.</returns>
    public X509Certificate2 LoadOrCreate()
    {
        string path = _options.SelfSignedCertPath;
        if (string.IsNullOrWhiteSpace(path))
        {
            throw new InvalidOperationException(
                "MxGateway:Tls:SelfSignedCertPath must be set when an HTTPS endpoint has no certificate.");
        }

        if (File.Exists(path))
        {
            try
            {
                X509Certificate2 existing = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
                if (existing.NotAfter.ToUniversalTime() > _timeProvider.GetUtcNow().UtcDateTime)
                {
                    Log("Loaded", existing);
                    return existing;
                }

                if (!_options.RegenerateIfExpired)
                {
                    string notAfter = existing.NotAfter.ToUniversalTime().ToString("u");
                    existing.Dispose();
                    throw new InvalidOperationException(
                        $"Persisted gateway certificate at '{path}' expired on {notAfter} " +
                        "and MxGateway:Tls:RegenerateIfExpired is false.");
                }

                _logger.LogWarning(
                    "Persisted gateway certificate at {Path} expired on {NotAfter:u}; regenerating.",
                    path, existing.NotAfter.ToUniversalTime());
                existing.Dispose();
            }
            catch (CryptographicException ex)
            {
                _logger.LogWarning(ex,
                    "Persisted gateway certificate at {Path} is unreadable; regenerating.", path);
            }
            catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
            {
                // A permission/IO error must fail fast: do not regenerate (which would
                // overwrite the operator's file), surface the cause instead.
                throw new InvalidOperationException(
                    $"Persisted gateway certificate at '{path}' could not be read; the gateway " +
                    "process lacks read access or the file is otherwise inaccessible.", ex);
            }
        }

        return GenerateAndPersist(path);
    }

    private X509Certificate2 GenerateAndPersist(string path)
    {
        using X509Certificate2 generated = GenerateCertificate();
        byte[] pfx = generated.Export(X509ContentType.Pkcs12);
        try
        {
            string? directory = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(directory))
            {
                Directory.CreateDirectory(directory);
            }

            // The private-key bytes must never touch a world-readable file. Create the
            // temp file empty, harden its permissions, and only then write the PFX into
            // the already-protected file. The temp path is in the same directory as the
            // target so the Move is atomic and preserves the hardened DACL/mode.
            //
            // The temp name carries a unique suffix rather than a fixed "<path>.tmp": two
            // processes (or two parallel callers) generating to the same target must not
            // collide on one temp file. On Windows a fixed name makes the second writer's
            // File.Create/Move fail with "the process cannot access the file ... because it
            // is being used by another process"; a unique name lets each generation stage
            // independently, and the final atomic Move (last-writer-wins) still yields a
            // valid, equivalent certificate at the shared path.
            string temp = $"{path}.{Guid.NewGuid():N}.tmp";
            using (File.Create(temp)) { }
            HardenPermissions(temp);

            // Writing into an existing file truncates content but preserves its ACL/mode.
            // If the write or move fails the hardened temp file (which may contain private-key
            // material) must not be left on disk; delete it best-effort before rethrowing.
            try
            {
                File.WriteAllBytes(temp, pfx);
                File.Move(temp, path, overwrite: true);
                HardenPermissions(path);
            }
            catch (Exception)
            {
                try { File.Delete(temp); } catch { /* best effort */ }
                throw;
            }

            X509Certificate2 loaded = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, KeyStorageFlags());
            Log("Generated", loaded);
            return loaded;
        }
        finally
        {
            // pfx holds raw private-key material; clear it as soon as it is no longer needed.
            Array.Clear(pfx, 0, pfx.Length);
        }
    }

    private static X509KeyStorageFlags KeyStorageFlags()
        => OperatingSystem.IsWindows()
            ? X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable
            : X509KeyStorageFlags.Exportable;

    private static void HardenPermissions(string path)
    {
        if (OperatingSystem.IsWindows())
        {
            HardenWindowsAcl(path);
        }
        else
        {
            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
        }
    }

    [System.Runtime.Versioning.SupportedOSPlatform("windows")]
    private static void HardenWindowsAcl(string path)
    {
        FileInfo file = new(path);
        System.Security.AccessControl.FileSecurity security = new();
        security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);

        foreach (System.Security.Principal.WellKnownSidType sid in new[]
        {
            System.Security.Principal.WellKnownSidType.LocalSystemSid,
            System.Security.Principal.WellKnownSidType.BuiltinAdministratorsSid,
        })
        {
            System.Security.Principal.SecurityIdentifier identifier = new(sid, null);
            security.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
                identifier,
                System.Security.AccessControl.FileSystemRights.FullControl,
                System.Security.AccessControl.AccessControlType.Allow));
        }

        file.SetAccessControl(security);
    }

    private void Log(string action, X509Certificate2 cert)
    {
        string sans = cert.Extensions
            .FirstOrDefault(e => e.Oid?.Value == SubjectAltNameOid)?
            .Format(false) ?? "(none)";
        _logger.LogInformation(
            "{Action} gateway self-signed certificate: thumbprint={Thumbprint}, notAfter={NotAfter:u}, sans={Sans}",
            action, cert.Thumbprint, cert.NotAfter.ToUniversalTime(), sans);
    }
}
