"""
    BIO Stream callbacks.
"""

"""
    Called to initialize new BIO Stream object.
"""
on_bio_stream_create(bio::BIO) = Cint(1)
on_bio_stream_destroy(bio::BIO)::Cint = Cint(0)

function bio_get_data(bio::BIO)
    data = ccall(
        (:BIO_get_data, libcrypto),
        Ptr{Cvoid},
        (BIO,),
        bio)
    return unsafe_pointer_to_objref(data)
end

const BIO_FLAGS_SHOULD_RETRY = 0x08
const BIO_FLAGS_READ = 0x01
const BIO_FLAGS_WRITE = 0x02
const BIO_FLAGS_IO_SPECIAL = 0x04

function bio_set_flags(bio::BIO, flags)
    return ccall(
        (:BIO_set_flags, libcrypto),
        Cint,
        (BIO, Cint),
        bio, flags)
end
bio_set_read_retry(bio::BIO) = bio_set_flags(bio, BIO_FLAGS_READ | BIO_FLAGS_SHOULD_RETRY)
bio_clear_flags(bio::BIO) = bio_set_flags(bio, 0x00)

function on_bio_stream_read(bio::BIO, out::Ptr{Cchar}, outlen::Cint)
    try
        bio_clear_flags(bio)
        io = bio_get_data(bio)::TCPSocket
        n = bytesavailable(io)
        if n == 0
            bio_set_read_retry(bio)
            return Cint(0)
        end
        unsafe_read(io, out, min(UInt(n), outlen))
        return Cint(min(n, outlen))
    catch e
        # we don't want to throw a Julia exception from a C callback
        return Cint(0)
    end
end

function on_bio_stream_write(bio::BIO, in::Ptr{Cchar}, inlen::Cint)::Cint
    try
        io = bio_get_data(bio)::TCPSocket
        written = unsafe_write(io, in, inlen)
        return Cint(written)
    catch e
        # we don't want to throw a Julia exception from a C callback
        return Cint(0)
    end
end

on_bio_stream_puts(bio::BIO, in::Ptr{Cchar})::Cint = Cint(0)

on_bio_stream_ctrl(bio::BIO, cmd::BIOCtrl, num::Clong, ptr::Ptr{Cvoid}) = Clong(1)

"""
    BIO Stream callbacks.
"""
struct BIOStreamCallbacks
    on_bio_create_ptr::Ptr{Nothing}
    on_bio_destroy_ptr::Ptr{Nothing}
    on_bio_read_ptr::Ptr{Nothing}
    on_bio_write_ptr::Ptr{Nothing}
    on_bio_puts_ptr::Ptr{Nothing}
    on_bio_ctrl_ptr::Ptr{Nothing}

    function BIOStreamCallbacks()
        on_bio_create_ptr = @cfunction on_bio_stream_create Cint (BIO,)
        on_bio_destroy_ptr = @cfunction on_bio_stream_destroy Cint (BIO,)
        on_bio_read_ptr = @cfunction on_bio_stream_read Cint (BIO, Ptr{Cchar}, Cint)
        on_bio_write_ptr = @cfunction on_bio_stream_write Cint (BIO, Ptr{Cchar}, Cint)
        on_bio_puts_ptr = @cfunction on_bio_stream_puts Cint (BIO, Ptr{Cchar})
        on_bio_ctrl_ptr = @cfunction on_bio_stream_ctrl Clong (BIO, BIOCtrl, Clong, Ptr{Cvoid})

        return new(
            on_bio_create_ptr,
            on_bio_destroy_ptr,
            on_bio_read_ptr,
            on_bio_write_ptr,
            on_bio_puts_ptr,
            on_bio_ctrl_ptr)
    end
end

"""
    SSLMethod.
    TLSClientMethod.
"""
mutable struct SSLMethod
    ssl_method::Ptr{Cvoid}
end

function TLSClientMethod()
    ssl_method = ccall(
        (:TLS_client_method, libssl),
        Ptr{Cvoid},
        ())
    if ssl_method == C_NULL
        throw(OpenSSLError())
    end

    return SSLMethod(ssl_method)
end

function TLSServerMethod()
    ssl_method = ccall(
        (:TLS_server_method, libssl),
        Ptr{Cvoid},
        ())
    if ssl_method == C_NULL
        throw(OpenSSLError())
    end

    return SSLMethod(ssl_method)
end

const SSL_MODE_AUTO_RETRY = 0x00000004

# Use NetworkOptions for default CA file so that it can be configured using the standard
# environment variables (JULIA_SSL_CA_ROOTS_PATH, SSL_CERT_DIR, and SSL_CERT_FILE).
# TODO: On Windows and macOS `ca_roots` return `nothing` to indicate that system configured
#       certificates should be preferred but for now we fall back to the certificate from
#       MozillaCACerts_jll.
default_cacert() = something(NetworkOptions.ca_roots(), MozillaCACerts_jll.cacert)

"""
    This is the global context structure which is created by a server or client once per program life-time
    and which holds mainly default values for the SSL structures which are later created for the connections.
"""
mutable struct SSLContext
    ssl_ctx::Ptr{Cvoid}

    function SSLContext(ssl_method::SSLMethod, verify_file::String = default_cacert())
        ssl_ctx = ccall(
            (:SSL_CTX_new, libssl),
            Ptr{Cvoid},
            (SSLMethod,),
            ssl_method)
        if ssl_ctx == C_NULL
            throw(OpenSSLError())
        end

        ssl_context = new(ssl_ctx)
        finalizer(free, ssl_context)

        # set auto retry mode
        ccall(
            (:SSL_CTX_ctrl, libssl),
            Cint,
            (SSLContext, Cint, Clong, Ptr{Cvoid}),
            ssl_context, 33, SSL_MODE_AUTO_RETRY, C_NULL)
        if !isempty(verify_file)
            ret = ca_chain!(ssl_context, verify_file)
            if ret != 1
                error("Failed to validate CA certificates at '$(verify_file)'.")
            end
        end

        return ssl_context
    end
end

function ca_chain!(ssl_context::SSLContext, cacert::String)

    if isfile(cacert)
        ccall(
            (:SSL_CTX_load_verify_locations, libssl),
            Cint,
            (SSLContext, Ptr{Cchar}, Ptr{Cchar}),
            ssl_context,
            cacert,
            C_NULL)
    elseif isdir(cacert)
        ccall(
            (:SSL_CTX_load_verify_locations, libssl),
            Cint,
            (SSLContext, Ptr{Cchar}, Ptr{Cchar}),
            ssl_context,
            C_NULL,
            cacert)
    else
        ArgumentError("Invalid CA certificates location: $cacert")
    end

end

function free(ssl_context::SSLContext)
    ssl_context.ssl_ctx == C_NULL && return
    ccall(
        (:SSL_CTX_free, libssl),
        Cvoid,
        (SSLContext,),
        ssl_context)

    ssl_context.ssl_ctx = C_NULL
    return
end

"""
    Sets the (external) protocol behaviour of the SSL library.
"""
function ssl_set_options(ssl_context::SSLContext, options::SSLOptions)::SSLOptions
    return ccall(
        (:SSL_CTX_set_options, libssl),
        SSLOptions,
        (SSLContext, SSLOptions),
        ssl_context,
        options)
end

"""
    Configures TLS ALPN (Application-Layer Protocol Negotiation).
"""
function ssl_set_alpn(ssl_context::SSLContext, protocol_list::String)
    if ccall(
        (:SSL_CTX_set_alpn_protos, libssl),
        Cint,
        (SSLContext, Ptr{UInt8}, UInt32),
        ssl_context,
        pointer(protocol_list),
        length(protocol_list)) != 0
        throw(OpenSSLError())
    end
end

"""
    Sets minimum supported protocol version for SSLContext.
"""
function ssl_set_min_protocol_version(ssl_context::SSLContext, version::TlsVersion)
    if ccall(
        (:SSL_CTX_ctrl, libssl),
        Cint,
        (SSLContext, SSLControlCommand, TlsVersion, Ptr{Cvoid}),
        ssl_context,
        SSL_CTRL_SET_MIN_PROTO_VERSION,
        version,
        C_NULL) != 1
        throw(OpenSSLError())
    end
end

# TODO
# int SSL_CTX_set_cipher_list(SSL_CTX *ctx, const char *str);

"""
    Configures available TLSv1.3 cipher suites.
"""
function ssl_set_ciphersuites(ssl_context::SSLContext, cipher_suites::String)
    if ccall(
        (:SSL_CTX_set_ciphersuites, libssl),
        Cint,
        (SSLContext, Cstring),
        ssl_context,
        cipher_suites) != 1
        throw(OpenSSLError())
    end
end

function ssl_use_certificate(ssl_context::SSLContext, x509_cert::X509Certificate)
    if ccall(
        (:SSL_CTX_use_certificate, libssl),
        Cint,
        (SSLContext, X509Certificate),
        ssl_context,
        x509_cert) != 1
        throw(OpenSSLError())
    end
end

function ssl_use_private_key(ssl_context::SSLContext, evp_pkey::EvpPKey)
    if ccall(
        (:SSL_CTX_use_PrivateKey, libssl),
        Cint,
        (SSLContext, EvpPKey),
        ssl_context,
        evp_pkey) != 1
        throw(OpenSSLError())
    end
end

"""
    SSL structure for a connection.
"""
mutable struct SSL
    ssl::Ptr{Cvoid}

    function SSL(ssl_context::SSLContext, read_bio::BIO, write_bio::BIO)::SSL
        ssl = ccall(
            (:SSL_new, libssl),
            Ptr{Cvoid},
            (SSLContext,),
            ssl_context)
        if ssl == C_NULL
            throw(OpenSSLError())
        end

        ssl = new(ssl)

        ccall(
            (:SSL_set_bio, libssl),
            Cvoid,
            (SSL, BIO, BIO),
            ssl,
            read_bio,
            write_bio)

        return ssl
    end
end

function free(ssl::SSL)
    ssl.ssl == C_NULL && return
    ccall(
        (:SSL_free, libssl),
        Cvoid,
        (SSL,),
        ssl)

    ssl.ssl = C_NULL
    return
end

function ssl_set_host(ssl::SSL, host)
    if (ret = ccall(
        (:SSL_set1_host, libssl),
        Cint,
        (SSL, Cstring),
        ssl, host)) != 1
        throw(OpenSSLError(ret))
    end
end

function ssl_connect(ssl::SSL)
    return ccall(
        (:SSL_connect, libssl),
        Cint,
        (SSL,),
        ssl)
end

function ssl_accept(ssl::SSL)
    if (ret = ccall(
        (:SSL_accept, libssl),
        Cint,
        (SSL,),
        ssl)) != 1
        throw(OpenSSLError(ret))
    end

    ccall(
        (:SSL_set_read_ahead, libssl),
        Cvoid,
        (SSL, Cint),
        ssl,
        Int32(1))
    return nothing
end

"""
    Shut down a TLS/SSL connection.
"""
function ssl_disconnect(ssl::SSL)
    ccall(
        (:SSL_shutdown, libssl),
        Cint,
        (SSL,),
        ssl)
    return nothing
end

function get_error(ssl::SSL, ret::Cint)::SSLErrorCode
    return ccall(
        (:SSL_get_error, libssl),
        SSLErrorCode,
        (SSL, Cint),
        ssl,
        ret)
end

"""
    SSLStream.
"""
mutable struct SSLStream <: IO
    ssl::SSL
    ssl_context::SSLContext
    rbio::BIO
    wbio::BIO
    io::TCPSocket
    # used in `eof` where we want the call to `eof` on the underlying
    # socket and the SSL_peek call that processes bytes to be seen
    # as one "operation"
    eoflock::ReentrantLock
    # this lock guards operations accessing our .ssl object and after acquiring
    # the lock, *MUST* check if .closed is true before proceeding
    # also this guards against 2 threads trying to
    # call `read` or `write` at the same time as per the thread in
    # https://mailing.openssl.users.narkive.com/HeNGlNAJ/openssl-and-multithreaded-programs
    lock::ReentrantLock
    readbytes::Base.RefValue{Csize_t}
    writebytes::Base.RefValue{Csize_t}
    peekbuf::Base.RefValue{UInt8}
    peekbytes::Base.RefValue{Csize_t}
    closed::Bool

    function SSLStream(ssl_context::SSLContext, io::TCPSocket)
        # Create a read and write BIOs.
        bio_read::BIO = BIO(io; finalize=false)
        bio_write::BIO = BIO(io; finalize=false)
        ssl = SSL(ssl_context, bio_read, bio_write)
        x = new(ssl, ssl_context, bio_read, bio_write, io, ReentrantLock(), ReentrantLock(), Ref{Csize_t}(0), Ref{Csize_t}(0), Ref{UInt8}(0x00), Ref{Csize_t}(0), false)
        finalizer(close, x)
        return x
    end
end

SSLStream(tcp::TCPSocket) = SSLStream(SSLContext(OpenSSL.TLSClientMethod()), tcp)

# backwards compat
Base.getproperty(ssl::SSLStream, nm::Symbol) = nm === :bio_read_stream ? ssl : getfield(ssl, nm)

Base.isreadable(ssl::SSLStream)::Bool = isopen(ssl) && isreadable(ssl.io)
Base.isopen(ssl::SSLStream)::Bool = Base.@lock(ssl.lock, !ssl.closed)
Base.iswritable(ssl::SSLStream)::Bool = isopen(ssl) && isopen(ssl.io)
@noinline throwio(op) = throw(Base.IOError("$op requires ssl to be open", 0))

# this is a macro, but should be a function, but closures are stupid slow
# we use this to standardize the error handling for all of the SSL_*_ex functions
macro geterror(ssl, op, expr)
    esc(quote
        # lock our SSLStream while we clear errors
        # make a ccall, then check the error queue
        Base.@lock ssl.lock begin
            # check that SSL is still open before ccall
            $ssl.closed && throwio($op)
            # clear the current error queue before openssl ccall
            clear_errors!()
            # do the ccall
            _ret = $expr
            # we want to return one of our SSL return codes, regardless of error
            # SSL_peek_ex, SSL_write_ex, SSL_connect, and SSL_read_ex all return 1 on success
            if _ret == 1
                ret = SSL_ERROR_NONE
            else
                err = get_error($ssl.ssl, _ret)
                if err == SSL_ERROR_ZERO_RETURN
                    # the peer sent a close_notify, so no more reading is possible
                    close($ssl, false)
                    throw(Base.IOError("unexpected EOF", 0))
                elseif err == SSL_ERROR_NONE
                    ret = SSL_ERROR_NONE
                elseif err == SSL_ERROR_WANT_READ
                    # we need to read more data from the underlying socket
                    ret = SSL_ERROR_WANT_READ
                elseif err == SSL_ERROR_WANT_WRITE
                    # we need to write more data to the underlying socket
                    # we don't expect to ever see this since we set up our SSL
                    # to do auto TLS (re)negotiation
                    ret = SSL_ERROR_WANT_WRITE
                else
                    # this is usually some other kind of error, like a protocol error
                    # or OS-level IO error, just close the SSL connection and throw
                    # notably, the openssl docs say we should *not* call ssl_disconnect
                    # in this case, hence the `false` arg to close
                    close($ssl, false)
                    throw(Base.IOError(OpenSSLError(err).msg, 0))
                end
            end
            ret
        end
    end)
end

function Base.unsafe_write(ssl::SSLStream, in_buffer::Ptr{UInt8}, in_length::UInt)
    nwritten = 0
    while nwritten < in_length
        ret = @geterror ssl :unsafe_write ccall(
            (:SSL_write_ex, libssl),
            Cint,
            (SSL, Ptr{Cvoid}, Cint, Ptr{Csize_t}),
            ssl.ssl,
            in_buffer,
            in_length,
            ssl.writebytes
        )
        if ret == SSL_ERROR_NONE
            nwritten += ssl.writebytes[]
        elseif ret == SSL_ERROR_WANT_WRITE
            flush(ssl.io)
        elseif ret == SSL_ERROR_WANT_READ
            # this means write is waiting for more data from the underlying socket
            # so call eof on the socket to wait for more bytes to come in
            eof(ssl.io) && throw(EOFError())
        end
    end
    return Base.bitcast(Int, in_length)
end

function Sockets.connect(ssl::SSLStream; require_ssl_verification::Bool=true)
    while true
        ret = @geterror ssl :connect ssl_connect(ssl.ssl)
        if ret == SSL_ERROR_NONE
            break
        elseif ret == SSL_ERROR_WANT_READ
            # this means connect is waiting for more data from the underlying socket
            # so call eof on the socket to wait for more bytes to come in
            eof(ssl.io) && throw(EOFError())
        else
            throw(Base.IOError(OpenSSLError(ret).msg, 0))
        end
    end

    # Check the certificate.
    if require_ssl_verification
        Base.@lock ssl.lock begin
            ssl.closed && throwio(:verify_result)
            if (ret = ccall(
                (:SSL_get_verify_result, libssl),
                Cint,
                (SSL,),
                ssl.ssl)) != 0
                throw(OpenSSLError(unsafe_string(ccall(
                    (:X509_verify_cert_error_string, libcrypto),
                    Ptr{UInt8},
                    (Cint,),
                    ret))))
            end
        end
        # get peer certificate
        cert = get_peer_certificate(ssl)
        cert === nothing && throw(OpenSSLError("No peer certificate"))
    end

    # set read ahead; this is a recommended optimization when we can guarantee
    # that an SSL connection will only ever be read from sequentially, which we do
    # by not doing any internal buffering
    Base.@lock ssl.lock begin
        ssl.closed && throwio(:read_ahead)
        ccall(
            (:SSL_set_read_ahead, libssl),
            Cvoid,
            (SSL, Cint),
            ssl.ssl,
            Cint(1))
    end
    return
end

const SSL_CTRL_SET_TLSEXT_HOSTNAME = 55
const TLSEXT_NAMETYPE_host_name = 0

function hostname!(ssl::SSLStream, host)
    Base.@lock ssl.lock begin
        ssl.closed && throwio(:hostname)
        if (ret = ccall(
            (:SSL_ctrl, libssl),
            Cint,
            (SSL, Cint, Clong, Cstring),
            ssl.ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME, TLSEXT_NAMETYPE_host_name, host)) != 1
            throw(OpenSSLError(get_error()))
        end
        ssl_set_host(ssl.ssl, host)
    end
end

function Sockets.accept(ssl::SSLStream)
    ssl_accept(ssl.ssl)
end

"""
    Read from the SSL stream.
"""
function Base.unsafe_read(ssl::SSLStream, buf::Ptr{UInt8}, nbytes::UInt)
    nread = 0
    readbytes = ssl.readbytes
    while nread < nbytes
        ret = @geterror ssl :unsafe_read ccall(
            (:SSL_read_ex, libssl),
            Cint,
            (SSL, Ptr{UInt8}, Csize_t, Ptr{Csize_t}),
            ssl.ssl,
            buf + nread,
            nbytes - nread,
            readbytes
        )
        if ret == SSL_ERROR_NONE
            nread += Base.bitcast(Int, readbytes[])
        elseif ret == SSL_ERROR_WANT_READ
            # this means write is waiting for more data from the underlying socket
            # so call eof on the socket to wait for more bytes to come in
            eof(ssl.io) && throw(EOFError())
        elseif ret == SSL_ERROR_WANT_WRITE
            flush(ssl.io)
        end
    end
    return nread
end

function Base.readavailable(ssl::SSLStream)
    N = bytesavailable(ssl)
    buf = Vector{UInt8}(undef, N)
    n = GC.@preserve buf unsafe_read(ssl, pointer(buf), N)
    return resize!(buf, n)
end

# returns the # of bytes that can be read immediately via unsafe_read
# i.e. # of processed, decrypted bytes available
function Base.bytesavailable(ssl::SSLStream)
    Base.@lock ssl.lock begin
        ssl.closed && return 0
        return Int(ccall(
            (:SSL_pending, libssl),
            Cint,
            (SSL,),
            ssl.ssl))
    end
end

# returns whether there are _any_ bytes buffered, processed
# or unprocessed, in the SSL stream
function haspending(ssl::SSLStream)
    Base.@lock ssl.lock begin
        ssl.closed && return false
        return 1 == ccall(
            (:SSL_has_pending, libssl),
            Cint,
            (SSL,),
            ssl.ssl)
    end
end

function Base.eof(ssl::SSLStream)::Bool
    bytesavailable(ssl) > 0 && return false
    while isopen(ssl)
        # note that care needs to be taken here to avoid a potential bad
        # race condition; for SSLStream, we have to manage the state of
        # the underlying socket having available bytes *and* whether they've
        # been processed in the ssl layer, so we want to treat the receiving and processing
        # of bytes as a single operation; in other words, bytesavailable returns
        # > 0 when bytes have been received *and* processed and we don't want
        # racing tasks to get stuck in between. We also don't really care whether
        # tasks are blocked calling eof on the socket or waiting on eoflock, so
        # we avoid the races and keep things orderly by only allowing one task
        # to make the eof call and kick off byte processing at a time.
        Base.@lock ssl.eoflock begin
            # check condition now that we have eoflock since another task may have
            # succeeded in getting bytes processed
            isopen(ssl) || return true
            bytesavailable(ssl) > 0 && return false
            # no processed bytes available, check if there are unprocessed bytes
            if !haspending(ssl)
                # no unprocessed bytes, call eof to get more unprocessed
                if eof(ssl.io) && !haspending(ssl)
                    # if eof and there are no pending, then we are eof
                    return true
                end
            end
            # at this point, we know there are at least unprocessed bytes
            # buffered, so we call SSL_peek to get the next record processed,
            # which still might not result in bytesavailable > 0
            ret = @geterror ssl :peek ccall(
                (:SSL_peek_ex, libssl),
                Cint,
                (SSL, Ptr{UInt8}, Cint, Ptr{Csize_t}),
                ssl.ssl,
                ssl.peekbuf,
                1,
                ssl.peekbytes
            )
            if ret == SSL_ERROR_NONE
                return false
            elseif ret == SSL_ERROR_WANT_WRITE
                flush(ssl.io)
            elseif ret == SSL_ERROR_WANT_READ
                # if we get WANT_READ back, that means there were pending bytes
                # to be processed, but not a full record, so we need to wait
                # for additional bytes to come in before we can process
                eof(ssl.io)
            end
        end
    end
    bytesavailable(ssl) > 0 && return false
    return !isopen(ssl)
end

"""
    Close SSL stream.
"""
function Base.close(ssl::SSLStream, shutdown::Bool=true)
    Base.@lock ssl.lock begin
        ssl.closed && return
        ssl.closed = true
        if shutdown
            try
                ssl_disconnect(ssl.ssl)
            catch err
                @debug "SSL disconnect failed" err
            end
        end
        free(ssl.ssl)
    end
    @async try
        Base.close(ssl.io)
    catch e
        e isa Base.IOError || rethrow()
    end
end

"""
    Gets the X509 certificate of the peer.
"""
function get_peer_certificate(ssl::SSLStream)::Option{X509Certificate}
    Base.@lock ssl.lock begin
        ssl.closed && throwio(:get_peer_certificate)
        x509 = ccall(
            (SSL_get_peer_certificate, libssl),
            Ptr{Cvoid},
            (SSL,),
            ssl.ssl)
        if x509 != C_NULL
            return X509Certificate(x509)
        else
            return nothing
        end
    end
end
