require "uri/punycode"
require "log"
{% if flag?(:win32) %}
  require "crystal/system/win32/crypto"
{% end %}

# An `SSL::Context` represents a generic secure socket protocol configuration.
#
# For both server and client applications exist more specialized subclassses
# `SSL::Context::Server` and `SSL::Context::Client` which need to be instantiated
# appropriately.
abstract class OpenSSL::SSL::Context
  # :nodoc:
  def self.default_method
    LibSSL.tls_method
  end

  class Client < Context
    @hostname : String?

    # Generates a new TLS client context with sane defaults for a client connection.
    #
    # Defaults to `TLS_method` or `SSLv23_method` (depending on OpenSSL version)
    # which tells OpenSSL to negotiate the TLS or SSL protocol with the remote
    # endpoint.
    #
    # Don't change the method unless you must restrict a specific protocol to be
    # used (eg: TLSv1.2) and nothing else. You should specify options to disable
    # specific protocols, yet allow to negotiate from various other ones. For
    # example the following snippet will enable the TLSv1, TLSv1.1 and TLSv1.2
    # protocols but disable the deprecated SSLv2 and SSLv3 protocols:
    #
    # ```
    # require "openssl"
    #
    # context = OpenSSL::SSL::Context::Client.new
    # context.add_options(OpenSSL::SSL::Options::NO_SSL_V2 | OpenSSL::SSL::Options::NO_SSL_V3)
    # ```
    def initialize(method : LibSSL::SSLMethod = Context.default_method)
      super(method)

      self.verify_mode = OpenSSL::SSL::VerifyMode::PEER
      self.default_verify_param = "ssl_server"
    end

    # Returns a new TLS client context with only the given method set.
    #
    # For everything else this uses the defaults of your OpenSSL.
    # Use this only if undoing the defaults that `new` sets is too much hassle.
    def self.insecure(method : LibSSL::SSLMethod = Context.default_method) : self
      super(method)
    end

    # Configures a client context from a hash-like interface.
    #
    # ```
    # require "openssl"
    #
    # context = OpenSSL::SSL::Context::Client.from_hash({"key" => "private.key", "cert" => "certificate.crt", "ca" => "ca.pem"})
    # ```
    #
    # Params:
    #
    # * `key` *(required)*: Path to private key file. See `#private_key=`.
    # * `cert` *(required)*: Path to the file containing the public certificate chain. See `#certificate_chain=`.
    # * `verify_mode`: Either `peer`, `force-peer`, `none` or empty (default: `peer`). See `verify_mode=`.
    # * `ca`: Path to a file containing the CA certificate chain or a directory containing all CA certificates.
    #    See `#ca_certificates=` and `#ca_certificates_path=`, respectively.
    #    Required if `verify_mode` is `peer`, `force-peer` or empty.
    def self.from_hash(params) : self
      super(params)
    end

    # Wraps the original certificate verification to also validate the
    # hostname against the certificate configured Subject Alternate
    # Names or Common Name.
    #
    # Required for OpenSSL <= 1.0.1 only.
    protected def set_cert_verify_callback(hostname : String)
      # Sanitize the hostname with PunyCode
      hostname = URI::Punycode.to_ascii hostname

      # Keep a reference so the GC doesn't collect it after sending it to C land
      @hostname = hostname
      LibSSL.ssl_ctx_set_cert_verify_callback(@handle, ->(x509_ctx, arg) {
        if LibCrypto.x509_verify_cert(x509_ctx) != 0
          cert = LibCrypto.x509_store_ctx_get_current_cert(x509_ctx)
          HostnameValidation.validate_hostname(arg.as(String), cert) == HostnameValidation::Result::MatchFound ? 1 : 0
        else
          0
        end
      }, hostname.as(Void*))
    end

    private def alpn_protocol=(protocol : Bytes)
      LibSSL.ssl_ctx_set_alpn_protos(@handle, protocol, protocol.size)
    end
  end

  class Server < Context
    # Keep a reference so the GC doesn't collect it after sending it to C land
    @sni_callback_box = Pointer(Void).null

    # Generates a new TLS server context with sane defaults for a server connection.
    #
    # Defaults to `TLS_method` or `SSLv23_method` (depending on OpenSSL version)
    # which tells OpenSSL to negotiate the TLS or SSL protocol with the remote
    # endpoint.
    #
    # Don't change the method unless you must restrict a specific protocol to be
    # used (eg: TLSv1.2) and nothing else. You should specify options to disable
    # specific protocols, yet allow to negotiate from various other ones. For
    # example the following snippet will enable the TLSv1, TLSv1.1 and TLSv1.2
    # protocols but disable the deprecated SSLv2 and SSLv3 protocols:
    #
    # ```
    # context = OpenSSL::SSL::Context::Server.new
    # context.add_options(OpenSSL::SSL::Options::NO_SSL_V2 | OpenSSL::SSL::Options::NO_SSL_V3)
    # ```
    def initialize(method : LibSSL::SSLMethod = Context.default_method)
      super(method)
      self.default_verify_param = "ssl_client"
    end

    # Returns a new TLS server context with only the given method set.
    #
    # For everything else this uses the defaults of your OpenSSL.
    # Use this only if undoing the defaults that `new` sets is too much hassle.
    def self.insecure(method : LibSSL::SSLMethod = Context.default_method) : self
      super(method)
    end

    # Configures a server from a hash-like interface.
    #
    # ```
    # require "openssl"
    #
    # context = OpenSSL::SSL::Context::Client.from_hash({"key" => "private.key", "cert" => "certificate.crt", "ca" => "ca.pem"})
    # ```
    #
    # Params:
    #
    # * `key` *(required)*: Path to private key file. See `#private_key=`.
    # * `cert` *(required)*: Path to the file containing the public certificate chain. See `#certificate_chain=`.
    # * `verify_mode`: Either `peer`, `force-peer`, `none` or empty (default: `none`). See `verify_mode=`.
    # * `ca`: Path to a file containing the CA certificate chain or a directory containing all CA certificates.
    #    See `#ca_certificates=` and `#ca_certificates_path=`, respectively.
    #    Required if `verify_mode` is `peer` or `force-peer`.
    def self.from_hash(params) : self
      super(params)
    end

    # Disables all session ticket generation for this context.
    # Tickets are used to resume earlier sessions more quickly,
    # but in TLS 1.3 if the client connects, sends data, and closes the connection
    # unidirectionally, the server connects, then sends a ticket
    # after the connect handshake, the ticket send can fail with Broken Pipe.
    # So if you have that kind of behavior (clients that never read) call this method.
    def disable_session_resume_tickets : Nil
      add_options(OpenSSL::SSL::Options::NO_TICKET) # TLS v1.2 and below
      {% if LibSSL.has_method?(:ssl_ctx_set_num_tickets) %}
        ret = LibSSL.ssl_ctx_set_num_tickets(self, 0) # TLS v1.3
        raise OpenSSL::Error.new("SSL_CTX_set_num_tickets") if ret != 1
      {% end %}
    end

    private def alpn_protocol=(protocol : Bytes)
      alpn_cb = ->(ssl : LibSSL::SSL, o : LibC::Char**, olen : LibC::Char*, i : LibC::Char*, ilen : LibC::Int, data : Void*) {
        proto = Box(Bytes).unbox(data)
        ret = LibSSL.ssl_select_next_proto(o, olen, proto, proto.size, i, ilen)
        if ret != LibSSL::OPENSSL_NPN_NEGOTIATED
          LibSSL::SSL_TLSEXT_ERR_NOACK
        else
          LibSSL::SSL_TLSEXT_ERR_OK
        end
      }
      @alpn_protocol = alpn_protocol = Box.box(protocol)
      LibSSL.ssl_ctx_set_alpn_select_cb(@handle, alpn_cb, alpn_protocol)
    end

    # Sets a Server Name Indication (SNI) callback for this server context.
    #
    # The callback receives the hostname from the client's SNI extension and should
    # return an `SSL::Context::Server` configured for that hostname, or `nil` to
    # continue using the current context.
    #
    # This allows a TLS server to present different certificates based on the
    # hostname the client is connecting to, enabling virtual hosting over TLS.
    #
    # Example:
    # ```
    # default_context = OpenSSL::SSL::Context::Server.new
    # default_context.certificate_chain = "default.crt"
    # default_context.private_key = "default.key"
    #
    # example_context = OpenSSL::SSL::Context::Server.new
    # example_context.certificate_chain = "example.com.crt"
    # example_context.private_key = "example.com.key"
    #
    # default_context.on_server_name do |hostname|
    #   case hostname
    #   when "example.com", "www.example.com"
    #     example_context
    #   else
    #     nil # use default context
    #   end
    # end
    # ```
    #
    # See [SSL_CTX_set_tlsext_servername_callback](https://docs.openssl.org/3.5/man3/SSL_CTX_set_tlsext_servername_callback/)
    @[Experimental]
    def on_server_name(&block : String -> OpenSSL::SSL::Context::Server?)
      # Create a C callback that extracts the hostname and calls our Crystal block
      c_callback = Proc(LibSSL::SSL, LibC::Int*, Void*, LibC::Int).new do |ssl, alert_ptr, arg|
        servername_ptr = LibSSL.ssl_get_servername(ssl, LibSSL::TLSExt::NAMETYPE_host_name)
        if servername_ptr.null?
          next LibSSL::SSL_TLSEXT_ERR_OK
        end

        begin
          hostname = String.new(servername_ptr)

          callback = Box(typeof(block)).unbox(arg)
          new_context = callback.call(hostname)

          if new_context
            LibSSL.ssl_set_ssl_ctx(ssl, new_context.to_unsafe)
          end

          LibSSL::SSL_TLSEXT_ERR_OK
        rescue
          alert_ptr.value = LibSSL::SSL_AD_INTERNAL_ERROR
          LibSSL::SSL_TLSEXT_ERR_ALERT_FATAL
        end
      end

      # Box the callback to pass to C
      callback_box = Box.box(block)
      @sni_callback_box = callback_box

      # Set the callback using SSL_CTX_callback_ctrl
      LibSSL.ssl_ctx_callback_ctrl(@handle, LibSSL::SSL_CTRL_SET_TLSEXT_SERVERNAME_CB, c_callback.unsafe_as(Proc(Void)))

      # Set the arg that will be passed to the callback
      LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_SET_TLSEXT_SERVERNAME_ARG, 0, callback_box)
    end
  end

  protected def initialize(method : LibSSL::SSLMethod)
    @handle = LibSSL.ssl_ctx_new(method)
    raise OpenSSL::Error.new("SSL_CTX_new") if @handle.null?

    set_default_verify_paths

    add_options(OpenSSL::SSL::Options.flags(
      ALL,
      NO_TLS_V1,
      NO_TLS_V1_1,
      NO_SESSION_RESUMPTION_ON_RENEGOTIATION,
      NO_RENEGOTIATION,
    ))
    add_modes(OpenSSL::SSL::Modes.flags(AUTO_RETRY, RELEASE_BUFFERS))

    # OpenSSL does not support reading from the system root certificate store on
    # Windows, so we have to import them ourselves
    {% if flag?(:win32) %}
      Crystal::System::Crypto.populate_system_root_certificates(self)
    {% end %}
  end

  # Overriding initialize or new in the child classes as public methods,
  # makes it either impossible to access the parent versions or makes the parent
  # versions public too. So to provide insecure in the child classes, we need
  # a second constructor that we call from there without getting the
  # overridden ones of the children.
  protected def _initialize_insecure(method : LibSSL::SSLMethod)
    @handle = LibSSL.ssl_ctx_new(method)
    raise OpenSSL::Error.new("SSL_CTX_new") if @handle.null?

    # since an insecure context on non-Windows systems still has access to the
    # system certificates, we do the same for Windows
    {% if flag?(:win32) %}
      Crystal::System::Crypto.populate_system_root_certificates(self)
    {% end %}
  end

  protected def self.insecure(method : LibSSL::SSLMethod)
    obj = allocate
    obj._initialize_insecure(method)
    GC.add_finalizer(obj)
    obj
  end

  def finalize
    LibSSL.ssl_ctx_free(@handle)
  end

  # Sets the default paths for `ca_certificates=` and `ca_certificates_path=`.
  def set_default_verify_paths
    LibSSL.ssl_ctx_set_default_verify_paths(@handle)
  end

  # Sets the path to a file containing all CA certificates, in PEM format, used to
  # validate the peers certificate.
  def ca_certificates=(file_path : String)
    ret = LibSSL.ssl_ctx_load_verify_locations(@handle, file_path, nil)
    raise OpenSSL::Error.new("SSL_CTX_load_verify_locations") unless ret == 1
  end

  # Sets the path to a directory containing all CA certificates used to
  # validate the peers certificate. The certificates should be in PEM format
  # and the `c_rehash(1)` utility must have been run in the directory.
  def ca_certificates_path=(dir_path : String)
    ret = LibSSL.ssl_ctx_load_verify_locations(@handle, nil, dir_path)
    raise OpenSSL::Error.new("SSL_CTX_load_verify_locations") unless ret == 1
  end

  # Specify the path to the certificate chain file to use. In server mode this
  # is presented to the client, in client mode this used as client certificate.
  def certificate_chain=(file_path : String)
    ret = LibSSL.ssl_ctx_use_certificate_chain_file(@handle, file_path)
    raise OpenSSL::Error.new("SSL_CTX_use_certificate_chain_file") unless ret == 1
  end

  # Specify the path to the private key to use. The key must in PEM format.
  # The key must correspond to the entity certificate set by `certificate_chain=`.
  def private_key=(file_path : String)
    ret = LibSSL.ssl_ctx_use_privatekey_file(@handle, file_path, LibSSL::SSLFileType::PEM)
    raise OpenSSL::Error.new("SSL_CTX_use_PrivateKey_file") unless ret == 1
  end

  # Specify a list of TLS ciphers to use or discard for TLSv1.2 and below.
  #
  # See `#security_level=` for some sensible system configuration.
  #
  # This method does not impact TLSv1.3 ciphersuites. Use `#cipher_suites=`
  # to configure those.
  #
  # NOTE: The ciphers available to an application are determined by the
  #       linked version of the system SSL library. A comprehensive list
  #       of ciphers can be found in the
  #       [OpenSSL Cipher documentation](https://www.openssl.org/docs/man3.0/man1/openssl-ciphers.html#CIPHER-STRINGS).
  def ciphers=(ciphers : String)
    ret = LibSSL.ssl_ctx_set_cipher_list(@handle, ciphers)
    raise OpenSSL::Error.new("SSL_CTX_set_cipher_list") if ret == 0
    ciphers
  end

  # Specify a list of TLS ciphersuites to use or discard for TLSv1.3.
  #
  # See `#security_level=` for some sensible system configuration.
  #
  # NOTE: The ciphersuites available to an application are determined by the
  #       linked version of the system SSL library. A comprehensive list
  #       of ciphersuites can be found in the
  #       [OpenSSL Cipher documentation](https://www.openssl.org/docs/man3.0/man1/openssl-ciphers.html#TLS-v1.3-cipher-suites).
  def cipher_suites=(cipher_suites : String)
    {% if LibSSL.has_method?(:ssl_ctx_set_ciphersuites) %}
      ret = LibSSL.ssl_ctx_set_ciphersuites(@handle, cipher_suites)
      raise OpenSSL::Error.new("SSL_CTX_set_ciphersuites") if ret == 0
    {% else %}
      Log.warn { "SSL_CTX_set_ciphersuites not supported" }
    {% end %}
    cipher_suites
  end

  # Sets the current ciphers and ciphers suites to **modern** compatibility level as per Mozilla
  # recommendations. See `#security_level=` for some sensible system configuration.
  #
  # WARNING: Does nothing as of Crystal 1.13.
  # WARNING: Didn't work as expected as of OpenSSL 1.1 (didn't configure TLSv1.2 and below).
  @[Deprecated("Deprecated with no replacement. Prefer #security_level, global system configuration or build your own from https://wiki.mozilla.org/Security/Server_Side_TLS")]
  def set_modern_ciphers
  end

  # Sets the current ciphers and ciphers suites to **intermediate** compatibility level as per Mozilla
  # recommendations. See `#security_level=` for some sensible system configuration.
  #
  # WARNING: Does nothing as of Crystal 1.13.
  # WARNING: Didn't work as expected as of OpenSSL 1.1 (didn't configure TLSv1.2 and below).
  @[Deprecated("Deprecated with no replacement. Prefer #security_level, global system configuration or build your own from https://wiki.mozilla.org/Security/Server_Side_TLS")]
  def set_intermediate_ciphers
  end

  # Sets the current ciphers and ciphers suites to **old** compatibility level as per Mozilla
  # recommendations. See `#security_level=` for some sensible system configuration.
  #
  # WARNING: Does nothing as of Crystal 1.13.
  # WARNING: Didn't work as expected as of OpenSSL 1.1 (didn't configure TLSv1.2 and below).
  @[Deprecated("Deprecated with no replacement. Prefer #security_level, global system configuration or build your own from https://wiki.mozilla.org/Security/Server_Side_TLS")]
  def set_old_ciphers
  end

  # Returns the security level used by this TLS context.
  def security_level : Int32
    {% if LibSSL.has_method?(:ssl_ctx_get_security_level) %}
      LibSSL.ssl_ctx_get_security_level(@handle)
    {% else %}
      Log.warn { "SSL_CTX_get_security_level not supported" }
      0
    {% end %}
  end

  # Sets the security level used by this TLS context. The default system
  # security level might disable some ciphers.
  #
  # * https://www.openssl.org/docs/man1.1.1/man3/SSL_CTX_set_security_level.html
  # * https://wiki.debian.org/ContinuousIntegration/TriagingTips/openssl-1.1.1
  def security_level=(value : Int32)
    {% if LibSSL.has_method?(:ssl_ctx_set_security_level) %}
      LibSSL.ssl_ctx_set_security_level(@handle, value)
    {% else %}
      Log.warn { "SSL_CTX_set_security_level not supported" }
    {% end %}
    value
  end

  # Adds a temporary ECDH key curve to the TLS context. This is required to
  # enable the EECDH cipher suites. By default the prime256 curve will be used.
  def set_tmp_ecdh_key(curve = LibCrypto::NID_X9_62_prime256v1) : Nil
    key = LibCrypto.ec_key_new_by_curve_name(curve)
    raise OpenSSL::Error.new("ec_key_new_by_curve_name") if key.null?
    LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_SET_TMP_ECDH, 0, key)
    LibCrypto.ec_key_free(key)
  end

  # Returns the current modes set on the TLS context.
  def modes : LibSSL::Modes
    OpenSSL::SSL::Modes.new LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_MODE, 0, nil)
  end

  # Adds modes to the TLS context.
  def add_modes(mode : OpenSSL::SSL::Modes)
    OpenSSL::SSL::Modes.new LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_MODE, mode, nil)
  end

  # Removes modes from the TLS context.
  def remove_modes(mode : OpenSSL::SSL::Modes)
    OpenSSL::SSL::Modes.new LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_CLEAR_MODE, mode, nil)
  end

  # Returns the current options set on the TLS context.
  def options : LibSSL::Options
    opts = {% if LibSSL.has_method?(:ssl_ctx_get_options) %}
             LibSSL.ssl_ctx_get_options(@handle)
           {% else %}
             LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_OPTIONS, 0, nil)
           {% end %}
    OpenSSL::SSL::Options.new(opts)
  end

  # Adds options to the TLS context.
  #
  # Example:
  # ```
  # context.add_options(
  #   OpenSSL::SSL::Options::ALL |       # various workarounds
  #   OpenSSL::SSL::Options::NO_SSL_V2 | # disable overly deprecated SSLv2
  #   OpenSSL::SSL::Options::NO_SSL_V3   # disable deprecated SSLv3
  # )
  # ```
  def add_options(options : OpenSSL::SSL::Options)
    opts = {% if LibSSL.has_method?(:ssl_ctx_set_options) %}
             LibSSL.ssl_ctx_set_options(@handle, options)
           {% else %}
             LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_OPTIONS, options, nil)
           {% end %}
    OpenSSL::SSL::Options.new(opts)
  end

  # Removes options from the TLS context.
  #
  # Example:
  # ```
  # context.remove_options(OpenSSL::SSL::Options::NO_SSL_V3)
  # ```
  def remove_options(options : OpenSSL::SSL::Options)
    opts = {% if LibSSL.has_method?(:ssl_ctx_clear_options) %}
             LibSSL.ssl_ctx_clear_options(@handle, options)
           {% else %}
             LibSSL.ssl_ctx_ctrl(@handle, LibSSL::SSL_CTRL_CLEAR_OPTIONS, options, nil)
           {% end %}
    OpenSSL::SSL::Options.new(opts)
  end

  # Returns the current verify mode. See the `SSL_CTX_set_verify(3)` manpage for more details.
  def verify_mode : LibSSL::VerifyMode
    LibSSL.ssl_ctx_get_verify_mode(@handle)
  end

  # Sets the verify mode. See the `SSL_CTX_set_verify(3)` manpage for more details.
  def verify_mode=(mode : OpenSSL::SSL::VerifyMode)
    LibSSL.ssl_ctx_set_verify(@handle, mode, nil)
  end

  @alpn_protocol = Pointer(Void).null

  # Specifies an ALPN protocol to negotiate with the remote endpoint. This is
  # required to negotiate HTTP/2 with browsers, since browser vendors decided
  # not to implement HTTP/2 over insecure connections.
  #
  # Example:
  # ```
  # context.alpn_protocol = "h2"
  # ```
  def alpn_protocol=(protocol : String)
    proto = Bytes.new(protocol.bytesize + 1)
    proto[0] = protocol.bytesize.to_u8
    protocol.to_slice.copy_to(proto.to_unsafe + 1, protocol.bytesize)
    self.alpn_protocol = proto
  end

  # Sets this context verify param to the default one of the given name.
  #
  # Depending on the OpenSSL version, the available defaults are
  # `default`, `pkcs7`, `smime_sign`, `ssl_client` and `ssl_server`.
  def default_verify_param=(name : String)
    param = LibCrypto.x509_verify_param_lookup(name)
    raise ArgumentError.new("#{name} is an unsupported default verify param") unless param
    ret = LibSSL.ssl_ctx_set1_param(@handle, param)
    raise OpenSSL::Error.new("SSL_CTX_set1_param") unless ret == 1
  end

  # Sets the given `OpenSSL::SSL::X509VerifyFlags` in this context, additionally to
  # the already set ones.
  def add_x509_verify_flags(flags : OpenSSL::SSL::X509VerifyFlags)
    param = LibSSL.ssl_ctx_get0_param(@handle)
    ret = LibCrypto.x509_verify_param_set_flags(param, flags)
    raise OpenSSL::Error.new("X509_VERIFY_PARAM_set_flags") unless ret == 1
  end

  def to_unsafe
    @handle
  end

  private def self.from_hash(params)
    context = new
    if key = params["key"]?
      context.private_key = key
    else
      raise ArgumentError.new("Invalid SSL context: missing private key ('key=')")
    end

    if cert = params["cert"]?
      context.certificate_chain = cert
    else
      raise ArgumentError.new("Invalid SSL context: missing certificate ('cert=')")
    end

    case verify_mode = params["verify_mode"]?
    when "peer"
      context.verify_mode = OpenSSL::SSL::VerifyMode::PEER
    when "force-peer"
      context.verify_mode = OpenSSL::SSL::VerifyMode::PEER | OpenSSL::SSL::VerifyMode::FAIL_IF_NO_PEER_CERT
    when "none"
      context.verify_mode = OpenSSL::SSL::VerifyMode::NONE
    when nil
      # use default
    else
      raise ArgumentError.new("Invalid SSL context: unknown verify mode #{verify_mode.inspect}")
    end

    if ca = params["ca"]?
      if File.directory?(ca)
        context.ca_certificates_path = ca
      else
        context.ca_certificates = ca
      end
    elsif context.verify_mode.peer? || context.verify_mode.fail_if_no_peer_cert?
      raise ArgumentError.new("Invalid SSL context: missing CA certificate ('ca=')")
    end

    context
  end
end
