# frozen_string_literal: true

require "openssl"

module HTTPX
  TLSError = OpenSSL::SSL::SSLError

  class SSL < TCP
    # rubocop:disable Style/MutableConstant
    TLS_OPTIONS = { alpn_protocols: %w[h2 http/1.1].freeze }
    # https://github.com/jruby/jruby-openssl/issues/284
    # TODO: remove when dropping support for jruby-openssl < 0.15.4
    TLS_OPTIONS[:verify_hostname] = true if RUBY_ENGINE == "jruby" && JOpenSSL::VERSION < "0.15.4"
    # rubocop:enable Style/MutableConstant
    TLS_OPTIONS.freeze

    attr_writer :ssl_session

    def initialize(_, _, options)
      super

      @ssl_session = nil
      ctx_options = TLS_OPTIONS.merge(options.ssl)
      @sni_hostname = ctx_options.delete(:hostname) || @hostname

      if @keep_open && @io.is_a?(OpenSSL::SSL::SSLSocket)
        # externally initiated ssl socket
        @ctx = @io.context
        @state = :negotiated
      else
        @ctx = OpenSSL::SSL::SSLContext.new
        @ctx.set_params(ctx_options) unless ctx_options.empty?
        unless @ctx.session_cache_mode.nil? # a dummy method on JRuby
          @ctx.session_cache_mode =
            OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE
        end

        yield(self) if block_given?
      end

      @verify_hostname = @ctx.verify_hostname
    end

    if OpenSSL::SSL::SSLContext.method_defined?(:session_new_cb=)
      def session_new_cb(&pr)
        @ctx.session_new_cb = proc { |_, sess| pr.call(sess) }
      end
    else
      # session_new_cb not implemented under JRuby
      def session_new_cb; end
    end

    def protocol
      # @type ivar @io: OpenSSL::SSL::SSLSocket
      @io.alpn_protocol || super
    rescue StandardError
      super
    end

    if RUBY_ENGINE == "jruby"
      # in jruby, alpn_protocol may return ""
      # https://github.com/jruby/jruby-openssl/issues/287
      def protocol
        # @type ivar @io: OpenSSL::SSL::SSLSocket
        proto = @io.alpn_protocol

        return super if proto.nil? || proto.empty?

        proto
      rescue StandardError
        super
      end
    end

    def can_verify_peer?
      @ctx.verify_mode == OpenSSL::SSL::VERIFY_PEER
    end

    def verify_hostname(host)
      return false if @ctx.verify_mode == OpenSSL::SSL::VERIFY_NONE
      # @type ivar @io: OpenSSL::SSL::SSLSocket
      return false if !@io.respond_to?(:peer_cert) || (peer_cert = @io.peer_cert).nil?

      OpenSSL::SSL.verify_certificate_identity(peer_cert, host)
    end

    def connected?
      @state == :negotiated
    end

    def ssl_session_expired?
      ssl_session = @ssl_session

      ssl_session.nil? || Process.clock_gettime(Process::CLOCK_REALTIME) >= (ssl_session.time.to_f + ssl_session.timeout)
    end

    def connect
      return if @state == :negotiated

      unless @state == :connected
        super
        return unless @state == :connected
      end

      # @type ivar @io: OpenSSL::SSL::SSLSocket

      unless @io.is_a?(OpenSSL::SSL::SSLSocket)
        if (hostname_is_ip = (@ip == @sni_hostname)) && @ctx.verify_hostname
          # IPv6 address would be "[::1]", must turn to "0000:0000:0000:0000:0000:0000:0000:0001" for cert SAN check
          @sni_hostname = @ip.to_string
          # IP addresses in SNI is not valid per RFC 6066, section 3.
          @ctx.verify_hostname = false
        end

        ssl = OpenSSL::SSL::SSLSocket.new(@io, @ctx)

        ssl.hostname = @sni_hostname unless hostname_is_ip
        ssl.session = @ssl_session unless ssl_session_expired?
        ssl.sync_close = true

        @io = ssl
      end
      try_ssl_connect
    end

    def try_ssl_connect
      # @type ivar @io: OpenSSL::SSL::SSLSocket
      ret = @io.connect_nonblock(exception: false)
      log(level: 3, color: :cyan) { "TLS CONNECT: #{ret}..." }
      case ret
      when :wait_readable
        @interests = :r
        return
      when :wait_writable
        @interests = :w
        return
      end
      @io.post_connection_check(@sni_hostname) if @ctx.verify_mode != OpenSSL::SSL::VERIFY_NONE && @verify_hostname
      transition(:negotiated)
      @interests = :w
    end

    private

    def transition(nextstate)
      case nextstate
      when :negotiated
        return unless @state == :connected

      when :closed
        return unless @state == :negotiated ||
                      @state == :connected
      end
      do_transition(nextstate)
    end

    def log_transition_state(nextstate)
      return super unless nextstate == :negotiated

      # @type ivar @io: OpenSSL::SSL::SSLSocket

      server_cert = @io.peer_cert #: OpenSSL::X509::Certificate

      "#{super}\n\n" \
        "SSL connection using #{@io.ssl_version} / #{Array(@io.cipher).first}\n" \
        "ALPN, server accepted to use #{protocol}\n" \
        "Server certificate:\n " \
        "subject: #{server_cert.subject}\n " \
        "start date: #{server_cert.not_before}\n " \
        "expire date: #{server_cert.not_after}\n " \
        "issuer: #{server_cert.issuer}\n " \
        "SSL certificate verify ok."
    end
  end
end
