// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Certificates;
using Microsoft.AspNetCore.Server.Kestrel.Https;
using Microsoft.AspNetCore.Server.Kestrel.Https.Internal;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace Microsoft.AspNetCore.Server.Kestrel.Core;

/// <summary>
/// An abstraction over the parts of <see cref="KestrelConfigurationLoader"/> that would prevent us from trimming TLS support
/// in `CreateSlimBuilder` scenarios.  Managed by <see cref="HttpsConfigurationService"/>.
/// </summary>
internal sealed class TlsConfigurationLoader
{
    private readonly ICertificateConfigLoader _certificateConfigLoader;
    private readonly string _applicationName;
    private readonly ILogger<KestrelServer> _serverLogger;
    private readonly ILogger<HttpsConnectionMiddleware> _httpsLogger;

    public TlsConfigurationLoader(
        IHostEnvironment hostEnvironment,
        ILogger<KestrelServer> serverLogger,
        ILogger<HttpsConnectionMiddleware> httpsLogger)
    {
        _certificateConfigLoader = new CertificateConfigLoader(hostEnvironment, serverLogger);
        _applicationName = hostEnvironment.ApplicationName;
        _serverLogger = serverLogger;
        _httpsLogger = httpsLogger;
    }

    /// <summary>
    /// Applies various configuration settings to <paramref name="httpsOptions"/> and <paramref name="endpoint"/>.
    /// </summary>
    public void ApplyHttpsConfiguration(
        HttpsConnectionAdapterOptions httpsOptions,
        EndpointConfig endpoint,
        KestrelServerOptions serverOptions,
        CertificateConfig? defaultCertificateConfig,
        ConfigurationReader configurationReader)
    {
        serverOptions.ApplyHttpsDefaults(httpsOptions);

        if (endpoint.SslProtocols.HasValue)
        {
            httpsOptions.SslProtocols = endpoint.SslProtocols.Value;
        }
        else
        {
            // Ensure endpoint is reloaded if it used the default protocol and the SslProtocols changed.
            endpoint.SslProtocols = configurationReader.EndpointDefaults.SslProtocols;
        }

        if (endpoint.ClientCertificateMode.HasValue)
        {
            httpsOptions.ClientCertificateMode = endpoint.ClientCertificateMode.Value;
        }
        else
        {
            // Ensure endpoint is reloaded if it used the default mode and the ClientCertificateMode changed.
            endpoint.ClientCertificateMode = configurationReader.EndpointDefaults.ClientCertificateMode;
        }

        // A cert specified directly on the endpoint overrides any defaults.
        var (serverCert, fullChain) = _certificateConfigLoader.LoadCertificate(endpoint.Certificate, endpoint.Name);
        httpsOptions.ServerCertificate = serverCert ?? httpsOptions.ServerCertificate;
        httpsOptions.ServerCertificateChain = fullChain ?? httpsOptions.ServerCertificateChain;

        if (!httpsOptions.HasServerCertificateOrSelector)
        {
            // Fallback
            serverOptions.ApplyDefaultCertificate(httpsOptions);

            // Ensure endpoint is reloaded if it used the default certificate and the certificate changed.
            endpoint.Certificate = defaultCertificateConfig;
        }
    }

    /// <summary>
    /// Calls an appropriate overload of <see cref="Microsoft.AspNetCore.Hosting.ListenOptionsHttpsExtensions.UseHttps(ListenOptions)"/>
    /// on <paramref name="listenOptions"/>, with or without SNI, according to how <paramref name="endpoint"/> is configured.
    /// </summary>
    /// <returns>Updated <see cref="ListenOptions"/> for convenient chaining.</returns>
    public ListenOptions UseHttpsWithSni(
        ListenOptions listenOptions,
        HttpsConnectionAdapterOptions httpsOptions,
        EndpointConfig endpoint)
    {
        if (listenOptions.IsTls)
        {
            return listenOptions;
        }

        if (endpoint.Sni.Count == 0)
        {
            if (!httpsOptions.HasServerCertificateOrSelector)
            {
                throw new InvalidOperationException(CoreStrings.NoCertSpecifiedNoDevelopmentCertificateFound);
            }

            return listenOptions.UseHttps(httpsOptions);
        }

        var sniOptionsSelector = new SniOptionsSelector(endpoint.Name, endpoint.Sni, _certificateConfigLoader,
            httpsOptions, listenOptions.Protocols, _httpsLogger);
        var tlsCallbackOptions = new TlsHandshakeCallbackOptions()
        {
            OnConnection = SniOptionsSelector.OptionsCallback,
            HandshakeTimeout = httpsOptions.HandshakeTimeout,
            OnConnectionState = sniOptionsSelector,
        };

        return listenOptions.UseHttps(tlsCallbackOptions);
    }

    /// <summary>
    /// Retrieves the default or, failing that, developer certificate from <paramref name="configurationReader"/>.
    /// </summary>
    public CertificateAndConfig? LoadDefaultCertificate(ConfigurationReader configurationReader)
    {
        if (configurationReader.Certificates.TryGetValue("Default", out var defaultCertConfig))
        {
            var (defaultCert, defaultCertChain) = _certificateConfigLoader.LoadCertificate(defaultCertConfig, "Default");
            if (defaultCert != null)
            {
                if (defaultCertChain != null)
                {
                    return new CertificateAndConfig(defaultCert, defaultCertConfig, defaultCertChain);
                }
                return new CertificateAndConfig(defaultCert, defaultCertConfig);
            }
        }
        else if (FindDeveloperCertificateFile(configurationReader) is CertificateAndConfig pair)
        {
            _serverLogger.LocatedDevelopmentCertificate(pair.Certificate);
            return pair;
        }

        return null;
    }

    private CertificateAndConfig? FindDeveloperCertificateFile(ConfigurationReader configurationReader)
    {
        string? certificatePath = null;
        if (configurationReader.Certificates.TryGetValue("Development", out var certificateConfig) &&
            certificateConfig.Path == null &&
            certificateConfig.Password != null &&
            TryGetCertificatePath(_applicationName, out certificatePath) &&
            File.Exists(certificatePath))
        {
            try
            {
                var certificate = new X509Certificate2(certificatePath, certificateConfig.Password);

                if (IsDevelopmentCertificate(certificate))
                {
                    return new CertificateAndConfig(certificate, certificateConfig);
                }
            }
            catch (CryptographicException)
            {
                _serverLogger.FailedToLoadDevelopmentCertificate(certificatePath);
            }
        }
        else if (!string.IsNullOrEmpty(certificatePath))
        {
            _serverLogger.FailedToLocateDevelopmentCertificateFile(certificatePath);
        }

        return null;
    }

    private static bool IsDevelopmentCertificate(X509Certificate2 certificate)
    {
        return string.Equals(certificate.Subject, CertificateManager.LocalhostHttpsDistinguishedName, StringComparison.Ordinal) &&
            CertificateManager.IsHttpsDevelopmentCertificate(certificate);
    }

    private static bool TryGetCertificatePath(string applicationName, [NotNullWhen(true)] out string? path)
    {
        // See https://github.com/aspnet/Hosting/issues/1294
        var appData = Environment.GetEnvironmentVariable("APPDATA");
        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var basePath = appData != null ? Path.Combine(appData, "ASP.NET", "https") : null;
        basePath = basePath ?? (home != null ? Path.Combine(home, ".aspnet", "https") : null);
        path = basePath != null ? Path.Combine(basePath, $"{applicationName}.pfx") : null;
        return path != null;
    }
}
