/*
 * Copyright (c) Huawei Technologies Co., Ltd. 2025. All rights reserved.
 * This source file is part of the Cangjie project, licensed under Apache-2.0
 * with Runtime Library Exception.
 *
 * See https://cangjie-lang.cn/pages/LICENSE for license information.
 */

package stdx.net.http

import std.collection.*
import std.net.*
import std.io.*
import std.sync.*
import std.unicode.UnicodeStringExtension
import std.process.Process
import std.env.*
import stdx.net.tls.common.*
import std.convert.Parsable
import stdx.encoding.url.*
import stdx.log.*

/*
 * Users have to build Client with ClientBuilder; Client doesn't have a public constructor
 */
public class ClientBuilder {
    private var _noProxy: Bool = false
    private var _http_proxy: String = ""
    private var _https_proxy: String = ""
    private var _connector: Connector = TcpSocketConnector
    private var _logger: Logger = mutexLogger()
    private var _cookieJar: ?CookieJar = CookieJarImpl(ArrayList<String>(), true)
    private var _poolSize: Int64 = 10
    private var _autoRedirect: Bool = true
    private var _tlsConfig: ?TlsConfig = None
    private var _readTimeout: Duration = Duration.second * 15
    private var _writeTimeout: Duration = Duration.second * 15
    private var _headerTableSize: UInt32 = 4096
    private var _enablePush: Bool = true
    private var _maxConcurrentStreams: UInt32 = UInt32(2 ** 31 - 1) // default no limit
    private var _initialWindowSize: UInt32 = DEFAULT_WINDOW_SIZE
    private var _maxFrameSize: UInt32 = MIN_FRAME_SIZE
    private var _maxHeaderListSize: UInt32 = UInt32.Max

    public init() {}

    /*
     * proxy config
     * By default, read "http_proxy", "https_proxy" from system env variables.
     * But if user configure proxy via this method, this proxy setting has higher priority than system proxy setting.
     *
     * @param addr http proxy's address.
     * @return ClientBuilder whose httpProxy has been set.
     */
    public func httpProxy(addr: String): ClientBuilder {
        _http_proxy = addr
        return this
    }

    /*
     * @param addr https proxy's address.
     * @return ClientBuilder whose httpsProxy has been set.
     */
    public func httpsProxy(addr: String): ClientBuilder {
        _https_proxy = addr
        return this
    }

    /*
     * clear all proxy settings
     *
     * @return ClientBuilder whose proxy settings are cleared.
     */
    public func noProxy(): ClientBuilder {
        _noProxy = true
        return this
    }

    /*
     * Note: type Connector = (String) -> StreamingSocket
     * Connector, a default connector will be provided.
     *
     * @param c set the ClientBuilder's Connector.
     * @return ClientBuilder whose Connector has been set.
     */
    public func connector(c: (SocketAddress) -> StreamingSocket): ClientBuilder {
        _connector = c
        return this
    }

    /*
     * the default logger will write to Console.stdout,
     * the default LogLevel is INFO, if set to DEBUG,  all handshake information, request, response will be logged.
     * setting logger.level will take effect immediately.
     * NOTE: logger should be thread-safe.
     *
     * @param logger set the ClientBuilder's logger.
     * @return ClientBuilder whose logger has been set.
     */
    public func logger(logger: Logger): ClientBuilder {
        _logger = logger
        return this
    }

    /*
     * CookieJar, the default value is an empty cookieJar.
     *
     * @param cookieJar set the ClientBuilder's cookieJar.
     *    if the param is None, cookie will be forbidden.
     * @return ClientBuilder whose cookieJar has been set.
     */
    public func cookieJar(cookieJar: ?CookieJar): ClientBuilder {
        _cookieJar = cookieJar
        return this
    }

    /*
     * Connection pool size for single host:port, if applicable, e.g. for Http/1.1 client implementation
     *
     * @param size set the ClientBuilder's poolSize. the size must greater than zero.
     * @return ClientBuilder whose poolSize has been set.
     *
     * @throws HttpException, if the size less than or equal to zero.
     */
    public func poolSize(size: Int64): ClientBuilder {
        if (size <= 0) {
            throw HttpException("The poolSize must be greater than 0.")
        }
        _poolSize = size
        return this
    }

    /*
     * Automatic redirection
     *
     * @param auto auto decide whether to enable automatic redirection.
     * @return ClientBuilder whose autoRedirect has been set.
     */
    public func autoRedirect(auto: Bool): ClientBuilder {
        _autoRedirect = auto
        return this
    }

    /*
     * Tls layer config, by default, tlsConfig will be set to none.
     *
     * @param config the tls configuration.
     * @return ClientBuilder whose tls has been configured.
     */
    public func tlsConfig(config: TlsConfig): ClientBuilder {
        _tlsConfig = config
        return this
    }

    /*
     * Read response timeout, the default value is 15s.
     *
     * @param timeout the read timeout configuration.
     * @return ClientBuilder whose readTimeout has been configured.
     */
    public func readTimeout(timeout: Duration): ClientBuilder {
        _readTimeout = checkDuration(timeout)
        return this
    }

    /*
     * Write request timeout, the default value is 15s.
     *
     * @param timeout the write timeout configuration.
     * @return ClientBuilder whose writeTimeout has been configured.
     */
    public func writeTimeout(timeout: Duration): ClientBuilder {
        _writeTimeout = checkDuration(timeout)
        return this
    }

    /*
     * h2 settings, these settings restrict the peer
     * In h2, Max header table size for hpack encoder/decoder, the default value is 4096.
     *
     * @param size the header table size.
     * @return ClientBuilder whose header table size has been set.
     */
    public func headerTableSize(size: UInt32): ClientBuilder {
        _headerTableSize = size
        return this
    }

    /*
     * In h2, server response pusher enable, the default value is true.
     *
     * @param enable enable decide whether to enable server response pusher.
     * @return ClientBuilder whose enablePush has been configured.
     */
    public func enablePush(enable: Bool): ClientBuilder {
        _enablePush = enable
        return this
    }

    /*
     * In h2, server response pusher enable, the default value is UInt32(2**31 - 1).
     *
     * @param size the max concurrent stream size.
     * @return ClientBuilder whose max concurrent stream size has been set.
     */
    public func maxConcurrentStreams(size: UInt32): ClientBuilder {
        _maxConcurrentStreams = size
        return this
    }

    /*
     * In h2, the initial window size, the default value is 65535.
     *
     * @param size the initial window size.
     * @return ClientBuilder whose initial window size has been set.
     */
    public func initialWindowSize(size: UInt32): ClientBuilder {
        _initialWindowSize = size
        return this
    }

    /*
     * In h2, max frame size, the default value is 16384.
     *
     * @param size the max frame size.
     * @return ClientBuilder whose max frame size has been set.
     */
    public func maxFrameSize(size: UInt32): ClientBuilder {
        _maxFrameSize = size
        return this
    }

    /*
     * In h2, max size of header decoded by hpack decoder, the default value is UInt32.Max.
     *
     * @param size the max header list size.
     * @return ClientBuilder whose max header list size has been set.
     */
    public func maxHeaderListSize(size: UInt32): ClientBuilder {
        _maxHeaderListSize = size
        return this
    }

    /**
     * @return Client instance.
     * @throws IllegalArgumentException if there is illegal config.
     */
    public func build(): Client {
        // check values
        if (_initialWindowSize == 0 || _initialWindowSize > MAX_WINDOW) {
            throw IllegalArgumentException("InitialWindowSize should between 1 and ${MAX_WINDOW}.")
        }
        if (_maxFrameSize < MIN_FRAME_SIZE || _maxFrameSize > MAX_FRAME_SIZE) {
            throw IllegalArgumentException("MaxFrameSize should between 2^14 and 2^24-1.")
        }
        if (_maxConcurrentStreams < 100) {
            _logger.warn("[ClientBuilder#build] Max concurrent streams num is recommended to be over 100.")
        }
        let client = Client()
        client._logger = _logger
        client._cookieJar = _cookieJar
        client._poolSize = _poolSize
        client._autoRedirect = _autoRedirect
        client._tlsConfig = _tlsConfig
        if (_tlsConfig?.supportedAlpnProtocols.contains("h2") ?? false) {
            client.enableH2 = true
        }
        client._connector = _connector
        client._readTimeout = _readTimeout
        client._writeTimeout = _writeTimeout
        client._headerTableSize = _headerTableSize
        client._enablePush = _enablePush
        client._maxConcurrentStreams = _maxConcurrentStreams
        client._initialWindowSize = _initialWindowSize
        client._maxFrameSize = _maxFrameSize
        client._maxHeaderListSize = _maxHeaderListSize
        // set proxy
        client._noProxy = _noProxy
        if (!_noProxy) {
            client._http_proxy = if (!_http_proxy.isEmpty()) {
                parseProxy(_http_proxy)
            } else {
                parseEnvironmentProxy(false)
            }
            client._https_proxy = if (!_https_proxy.isEmpty()) {
                parseProxy(_https_proxy)
            } else {
                parseEnvironmentProxy(true)
            }
        }
        return client
    }

    private func parseProxy(proxy: String): String {
        if (proxy.isEmpty()) {
            return proxy
        }
        if (!proxy.startsWith("http://")) {
            throw IllegalArgumentException("Proxy must have http scheme.")
        }
        let url = URL.parse(proxy)
        if (url.hostName.isEmpty()) {
            return ""
        }
        return proxy
    }

    private func parseEnvironmentProxy(isTls: Bool): String {
        let environment = if (isTls) {
            getVariable("https_proxy") ?? getVariable("HTTPS_PROXY") ?? ""
        } else {
            getVariable("http_proxy") ?? getVariable("HTTP_PROXY") ?? ""
        }
        return parseProxy(environment)
    }
}

public class Client { // cjlint-ignore !G.ENU.01
    let isClosed: AtomicBool = AtomicBool(false)
    private var client1_1: ?HttpClient1 = None
    private var client2_0: ?HttpClient2 = None
    var enableH2: Bool = false
    var _noProxy: Bool = false
    var _http_proxy: String = ""
    var _https_proxy: String = ""
    var _connector: Connector = TcpSocketConnector
    var _logger: Logger = mutexLogger()
    var _cookieJar: ?CookieJar = CookieJarImpl(ArrayList<String>(), true)
    var _poolSize: Int64 = 10
    var _autoRedirect: Bool = true
    var _tlsConfig: ?TlsConfig = None
    var _readTimeout: Duration = Duration.second * 15
    var _writeTimeout: Duration = Duration.second * 15
    var _headerTableSize: UInt32 = 4096
    var _enablePush: Bool = true
    var _maxConcurrentStreams: UInt32 = UInt32(2 ** 31 - 1)
    var _initialWindowSize: UInt32 = 65535
    var _maxFrameSize: UInt32 = MIN_FRAME_SIZE
    var _maxHeaderListSize: UInt32 = UInt32.Max
    // only used when create httpClient, seldom affects performance
    private let singletonLock: Mutex = Mutex()

    init() {}

    /**
     * proxy setting
     */
    public prop httpProxy: String {
        get() {
            _http_proxy
        }
    }

    public prop httpsProxy: String {
        get() {
            _https_proxy
        }
    }

    /**
     * Logger.
     * setting logger.level will take effect immediately.
     * NOTE: logger should be thread-safe.
     */
    public prop logger: Logger {
        get() {
            _logger
        }
    }

    /**
     * Note: type Connector = (String) -> StreamingSocket
     */
    public prop connector: Connector {
        get() {
            _connector
        }
    }

    /**
     * CookieJar.
     */
    public prop cookieJar: ?CookieJar {
        get() {
            _cookieJar
        }
    }

    /**
     * Connection pool size
     */
    public prop poolSize: Int64 {
        get() {
            _poolSize
        }
    }

    /**
     * Automatic redirection
     */
    public prop autoRedirect: Bool {
        get() {
            _autoRedirect
        }
    }

    /**
     * Tls layer config.
     * @return Clone of TLS configuration in client.
     */
    public func getTlsConfig(): ?TlsConfig {
        _tlsConfig
    }

    /**
     * Read response timeout.
     */
    public prop readTimeout: Duration {
        get() {
            _readTimeout
        }
    }

    /**
     * Write request timeout.
     */
    public prop writeTimeout: Duration {
        get() {
            _writeTimeout
        }
    }

    /**
     * h2 settings, these settings restrict the peer
     * In h2, Max header table size for hpack encoder/decoder.
     */
    public prop headerTableSize: UInt32 {
        get() {
            _headerTableSize
        }
    }

    /**
     * In h2, server response pusher enable.
     */
    public prop enablePush: Bool {
        get() {
            _enablePush
        }
    }

    /**
     * In h2, max number of concurrent streams per connection.
     */
    public prop maxConcurrentStreams: UInt32 {
        get() {
            _maxConcurrentStreams
        }
    }

    /**
     * In h2, init window size.
     */
    public prop initialWindowSize: UInt32 {
        get() {
            _initialWindowSize
        }
    }

    /**
     * In h2, max frame size.
     */
    public prop maxFrameSize: UInt32 {
        get() {
            _maxFrameSize
        }
    }

    /**
     * In h2, max size of header decoded by hpack decoder.
     */
    public prop maxHeaderListSize: UInt32 {
        get() {
            _maxHeaderListSize
        }
    }

    /*
     * close active connections and close this client
     *
     * @throws HttpException, If failed to close.
     */
    public func close(): Unit {
        if (!isClosed.swap(true)) {
            client1_1?.close()
            client2_0?.close()
        }
    }

    /*
     * get and invoke low level client to send request
     * do redirect in this level
     * do proxy in low level client
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     */
    func doRequest(req: HttpRequest): HttpResponse {
        var lastReq = req
        checkReq(lastReq)
        setCookie(lastReq)
        // a client must not generate fields in a TRACE request containing
        // sensitive data that might be disclosed by the response.
        // such as Cookie
        // RFC 9110 9.3.8. TRACE
        if (lastReq.method == "TRACE") {
            removeSensitiveHeaders(lastReq.headers)
        }
        var response: HttpResponse = requestWithNegotiateRetry(lastReq)
        resolveCookie(lastReq.url, response)
        // record requests , if redirect times exceed a num eg:10 ,throw infinite loop exception
        var reqsCnt = 0
        // do redirect if autoRedirect on
        while (autoRedirect && needRedirect(response.status)) {
            httpLogDebug(logger, "[Client#doRequest] request auto redirect")
            httpLogTrace(logger, "[Client#doRequest] redirect response: \n${response}")
            // read in case response have body, ensure conn can be used next time
            match (response.body) {
                case _: HttpEmptyBody => match ((lastReq.headers.getInternal("upgrade"), response.connNode)) {
                    case (Some(_), Some(connNode)) => connNode.returnConn()
                    case _ => ()
                }
                case body: InputStream =>
                    let tmpBuf = Array<UInt8>(64, repeat: 0)
                    while (body.read(tmpBuf) > 0) {}
            }
            if (reqsCnt > 10) {
                throw HttpException("Redirect loop exceed 10 times.")
            }
            reqsCnt++
            (lastReq, response) = redirectOnce(lastReq, response)
        }
        if (logger.enabled(LogLevel.DEBUG)) {
            httpLogDebug(logger, "[Client#doRequest] request finished, response: \n${response}")
        }
        return response
    }

    /*
     * check is request legal
     */
    private func checkReq(req: HttpRequest) {
        // method is checked in request build
        var url = req.url
        if (url.scheme != "http" && url.scheme != "https") {
            throw HttpException("Not HTTP protocol scheme: ${url.scheme}.")
        }
        if (url.hostName.isEmpty()) {
            throw HttpException("No host in request URL.")
        }
        if (url.path.isEmpty() && req.method != "OPTIONS") { // use OPTIONS on a host the target is *
            url = url.replace(path: SLASH)
        }
        if (!url.rawUserInfo.username().isEmpty()) {
            let auth = match (req.headers.getInternal("authorization")) {
                case Some(v) => v.size > 0
                case None => false
            }
            if (!auth) {
                // if % appear in userinfo, it will be decode by url, should use rawUserInfo here
                let basicAuth = "Basic ${basicAuth(url.rawUserInfo.username(), url.rawUserInfo.password() ?? "")}"
                req.headers.add("authorization", basicAuth)
            }
            // should remove userinfo after parse
            url = url.replace(userInfo: "")
        }
        req._url = url
        // for redirect, swap body to buffered body
        match (req._body) {
            case _: HttpRawBody => ()
            case _: HttpEmptyBody => ()
            case _ => req._body = HttpBufferedBody(req._body)
        }
    }

    private func requestWithNegotiateRetry(req: HttpRequest): HttpResponse {
        try {
            httpLogDebug(logger, "[Client#doRequest] start send request")
            let client = getClient(req)
            if (logger.enabled(LogLevel.DEBUG)) {
                httpLogDebug(logger, "[Client#doRequest] send request: \n${req}")
            }
            return client.request(req)
        } catch (e: NegotiateException) {
            if (req.headers.getFirst(":protocol").isSome()) {
                throw HttpException("H2 upgrade failed, since ALPN negotiate failed.")
            }
            req._version = HTTP1_1
            httpLogDebug(logger, "[Client#doRequest] h2 negotiate failed, and try h1 request again")
            return getClient(req).request(req)
        }
    }

    private func redirectOnce(lastReq: HttpRequest, response: HttpResponse): (HttpRequest, HttpResponse) {
        let location = match (response.headers.getInternal("location")) {
            case Some(v) =>
                // Location = URI-reference
                // The field value consists of a single URI-reference.
                // RFC 9110 10.2.2.
                if (v.size != 1) {
                    throw HttpException("Response's Location header can only contain a single URI-reference.")
                }
                v.single
            case None => throw HttpException("Response missing Location header, status code: ${response.status}.")
        }
        let requestMethod = match (response.status) {
            case 301 | 302 => match {
                case lastReq.method != "HEAD" => "GET"
                case _ => "HEAD"
            }
            case 303 => "GET"
            case _ => lastReq.method
        }
        let newUrl = lastReq.url.resolveURL(URL.parse(location))
        httpLogDebug(logger, "[Client#doRequest] redirect url is ${newUrl}")
        let referer = getReferer(lastReq.url, newUrl)
        let redirectReq = if (requestMethod == "GET") {
            // If the request method is changed to GET, the body, "Transfer-Encoding: chunked",
            // "Content-Type" and "Content-Length" must be removed.
            let req = HttpRequestBuilder(lastReq).method(requestMethod).url(newUrl).body([]).build()
            req.headers.del("transfer-encoding")
            req.headers.del("content-type")
            req.headers.del("content-length")
            req
        } else {
            let body = match (lastReq._body) {
                case body: HttpRawBody => body.rawBody
                case body: HttpBufferedBody => body.bytes
                case _ => Array<UInt8>()
            }
            HttpRequestBuilder(lastReq).method(requestMethod).url(newUrl).body(body).build()
        }
        // resolve header
        // remove some headers which is generated by client or related to safety
        // referer and cookie is set later, so removeHeader did`t remove them
        removeHeaders(redirectReq.headers)
        if (!referer.isEmpty()) {
            redirectReq.headers.set("referer", referer)
        }
        setCookie(redirectReq)
        if (redirectReq.method == "TRACE") {
            removeSensitiveHeaders(redirectReq.headers)
        }
        let newResponse = requestWithNegotiateRetry(redirectReq)
        resolveCookie(newUrl, newResponse)
        (redirectReq, newResponse)
    }

    // cjlint-ignore -start !G.OTH.03 
    /*
     * https://www.rfc-editor.org/rfc/rfc9110.html#field.referer
     * A user agent MUST NOT send a Referer header field in an unsecured HTTP request if the referring resource was accessed with a secure protocol.
     */
    // cjlint-ignore -end
    private func getReferer(lastUrl: URL, newUrl: URL): String {
        if (lastUrl.scheme == "https" && newUrl.scheme == "http") {
            return ""
        }
        var url = lastUrl
        if (!url.userInfo.username().isEmpty()) {
            url = if (url.fragment.isSome()) {
                url.replace(userInfo: "", fragment: "")
            } else {
                url.replace(userInfo: "")
            }
        }
        return url.toString()
    }

    private func removeHeaders(headers: HttpHeaders): Unit {
        headers.del("host")
        headers.del("authorization")
        headers.del("proxy-authenticate")
        headers.del("proxy-authorization")
        headers.del("www-authenticate")
        headers.del("cookie2")
    }

    private func removeSensitiveHeaders(headers: HttpHeaders): Unit {
        headers.del("authorization")
        headers.del("proxy-authenticate")
        headers.del("proxy-authorization")
        headers.del("www-authenticate")
        headers.del("cookie2")
        headers.del("cookie")
    }

    private func resolveCookie(reqUrl: URL, resp: HttpResponse) {
        if (let Some(cookieJar_) <- cookieJar) {
            let cookies = CookieJar.parseSetCookieHeader(resp)
            if (cookies.size > 0) {
                httpLogDebug(logger, "[Client#resolveCookie] Store cookie from domain:${reqUrl}")
                cookieJar_.storeCookies(reqUrl, cookies)
            }
        }
    }

    private func setCookie(req: HttpRequest) {
        if (let Some(cookieJar_) <- cookieJar) {
            if (let Some(cookieJarImpl) <- (cookieJar_ as CookieJarImpl)) {
                if (let Some(cookie) <- cookieJarImpl.cookies(req.url)) {
                    doSetCookie(cookie, req)
                }
                return
            }

            let cookie = cookieJar_.getCookies(req.url)
            doSetCookie(cookie, req)
        }
    }

    private func doSetCookie(cookie: ArrayList<Cookie>, req: HttpRequest) {
        if (cookie.size > 0) {
            httpLogDebug(logger, "[Client#setCookie] Add cookie to request")
            req.headers.set("cookie", CookieJar.toCookieString(cookie))
        }
    }

    /*
     * Send the request to server, block to get response.
     *
     * @param req the request to be sent to the server.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * or send an upgrade request
     * or send a TRACE request with a non empty body.
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws ConnectionException if conn is closed by peer.
     */
    public func send(req: HttpRequest): HttpResponse {
        if (isClosed.load()) {
            throw HttpException("This client has already closed.")
        }
        // websocket can be upgraded only by using the upgradeFromClient method.
        if (req.headers.getFirst("upgrade").isSome()) {
            throw HttpException("Request header Upgrade is only supported in method upgrade or upgradeFromClient.")
        }
        if (req.method == "CONNECT") {
            throw HttpException("Please use connect method to send a CONNECT request.")
        }
        // a client must not send content in a TRACE request.
        // RFC 9110 9.3.8.
        if (req.method == "TRACE" && !(req.body is HttpEmptyBody)) {
            throw HttpException("TRACE request can not contain content.")
        }
        return doRequest(req)
    }

    /*
     * Send GET request to server, block to get response.
     *
     * @param url the target url.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func get(url: String): HttpResponse {
        let req = HttpRequestBuilder().get().url(url).build()
        return send(req)
    }

    /*
     * Send HEAD request to server, block to get response.
     *
     * @param url the target url.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func head(url: String): HttpResponse {
        let req = HttpRequestBuilder().head().url(url).build()
        return send(req)
    }

    /*
     * Send PUT request to server, block to get response.
     *
     * @param url the target url.
     * @param body the body to be sent.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func put(url: String, body: String): HttpResponse {
        put(url, HttpRawBody(body))
    }

    public func put(url: String, body: Array<UInt8>): HttpResponse {
        put(url, HttpRawBody(body))
    }

    public func put(url: String, body: InputStream): HttpResponse {
        let req = HttpRequestBuilder().put().url(url).body(body).build()
        return send(req)
    }

    /*
     * Send POST request to server, block to get response.
     *
     * @param url the target url.
     * @param body the body to be sent.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func post(url: String, body: String): HttpResponse {
        post(url, HttpRawBody(body))
    }

    public func post(url: String, body: Array<UInt8>): HttpResponse {
        post(url, HttpRawBody(body))
    }

    public func post(url: String, body: InputStream): HttpResponse {
        let req = HttpRequestBuilder().post().url(url).body(body).build()
        return send(req)
    }

    /*
     * Send DELETE request to server, block to get response.
     * @param url the target url.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func delete(url: String): HttpResponse {
        let req = HttpRequestBuilder().delete().url(url).build()
        return send(req)
    }

    /*
     * Send CONNECT request to server, block to get response.
     * connect request does not support automatic redirection.
     * @param url the target url.
     * @param header the request header.
     * @param version the request version.
     * @return HttpResponse.
     * @return StreamingSocket is CONNECT is success, None if failed(no 2xx response)
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func connect(url: String, header!: HttpHeaders = HttpHeaders(), version!: Protocol = HTTP1_1): (HttpResponse, 
        ?StreamingSocket) {
        if (isClosed.load()) {
            throw HttpException("This client has already closed.")
        }
        let req = HttpRequestBuilder().connect().url(url).version(version).setHeaders(header).build()
        checkReq(req)
        setCookie(req)
        if (req.version == HTTP2_0 && !enableH2) {
            throw HttpException("HTTP/2 is not enabled.")
        }
        let client = getClient(req)

        let (resp, conn) = client.connect(req)
        resolveCookie(req.url, resp)
        return (resp, conn)
    }

    /*
     * Send OPTIONS request to server, block to get response.
     * @param url the target url.
     * @return HttpResponse.
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     * @throws UrlSyntaxException if 'url' is empty or invalid.
     * @throws IllegalArgumentException if there is an invalid UTF-8 leading code in 'url'.
     */
    public func options(url: String): HttpResponse {
        let req = HttpRequestBuilder().options().url(url).build()
        return send(req)
    }

    /*
     * Send upgrade request.
     * the upgrade request is constructed by user and the connection returned will be maintained by user.
     * HTTP/1.0 and HTTP/2 do not support upgrade.
     * @param req the request to be sent to the server.
     * @return HttpResponse
     * @return StreamingSocket if upgrade is success, None if failed(no 101 response)
     *
     * @throws HttpException, if request or response didn't conform to the protocol
     * or request header does not contain Upgrade header,
     * or send a request other than HTTP/1.1,
     * or send a CONNECT request,
     * or send a TRACE request with a non empty body.
     * @throws SocketException or ConnectionException, if connection to server broken
     * @throws TlsException, if something wrong happened in TLS.
     */
    public func upgrade(req: HttpRequest): (HttpResponse, ?StreamingSocket) {
        if (isClosed.load()) {
            throw HttpException("This client has already closed.")
        }
        if (req.headers.getFirst("upgrade").isNone()) {
            throw HttpException("Request header must contain Upgrade header.")
        }
        if (req.version == UnknownProtocol("HTTP/1.1")) {
            req._version = HTTP1_1
        }
        if (req.version != HTTP1_1) {
            throw HttpException("Only HTTP/1.1 support upgrade.")
        }
        // a client must not send content in a TRACE request.
        // RFC 9110 9.3.8.
        if (req.method == "TRACE" && !(req.body is HttpEmptyBody)) {
            throw HttpException("TRACE request can not contain content.")
        }
        // CONNECT method is not supported by upgrade
        if (req.method == "CONNECT") {
            throw HttpException("CONNECT request is not supported by upgrade.")
        }
        let resp = doRequest(req)
        let connNode = match (resp.connNode.getOrThrow() as ConnNode) {
            case Some(v) => v
            case None => throw HttpException("Get connection failed.")
        }
        // a server may ignore a received Upgrade header field if it
        // wishes to continue using the current protocol on that connection.
        // RFC 9110    7.8.
        // treat no 101 response as normal response
        if (resp.status == HttpStatusCode.STATUS_SWITCHING_PROTOCOLS) {
            return (resp, connNode.h1Engine.extractFromConnInUse(connNode))
        } else {
            if (resp.body is HttpEmptyBody) {
                connNode.h1Engine.returnConn(connNode)
            }
            return (resp, None)
        }
    }

    /*
     * choose protocol, only h1 & h2 supported
     * proxy implement in lowlevel client
     */
    private func getClient(request: HttpRequest): HttpClient {
        match ((request.version, enableH2)) {
            case (HTTP1_1, _) | (HTTP2_0, false) | (UnknownProtocol("HTTP/1.1"), false) =>
                if (request.headers.getFirst(":protocol").isSome()) {
                    throw HttpException("HTTP/2 is not enabled.")
                }
                httpLogDebug(logger, "[Client#getClient] Using HTTP/1.1 client")
                request._version = HTTP1_1
                getOrCreateH1()
            case (HTTP2_0, true) | (UnknownProtocol("HTTP/1.1"), true) =>
                httpLogDebug(logger, "[Client#getClient] Using HTTP/2 client")
                request._version = HTTP2_0
                getOrCreateH2()
            case (HTTP1_0, _) => throw HttpException("HTTP/1.0 request is not supported.")
            case _ => throw HttpException("Protocol unknown.")
        }
    }

    private func getOrCreateH1(): HttpClient {
        if (let Some(client) <- client1_1) {
            return client
        }

        synchronized(singletonLock) {
            if (let Some(client) <- client1_1) {
                return client
            }

            let client = HttpClient1(this)
            client1_1 = client
            return client
        }
    }

    private func getOrCreateH2(): HttpClient {
        match (client2_0) {
            case Some(client) => client
            case None => synchronized(singletonLock) {
                match (client2_0) {
                    case Some(client) => client
                    case None =>
                        let client = HttpClient2(this)
                        client2_0 = client
                        client
                }
            }
        }
    }
}

/*
 * HttpClient represent clients with different protocols, currently 1.1 & 2.0 implemented
 */
interface HttpClient {
    func request(req: HttpRequest): HttpResponse
    func connect(req: HttpRequest): (HttpResponse, ?StreamingSocket)
}

func normalizeHostLower(host: String): String {
    var h = host.trimAscii()

    // Strip optional IPv6 brackets: [::1] -> ::1
    if (h.size >= 2 && h[0] == b'[' && h[h.size - 1] == b']') {
        h = h[1..h.size - 1]
    }

    h = h.toAsciiLower()

    // Strip trailing dot (FQDN): example.com. -> example.com
    if (h.endsWith(".")) {
        h = h[..h.size - 1]
    }
    return h
}

func splitNoProxyHostPort(domain: String): (String, String) {
    let s = domain.trimAscii()

    // [ipv6]:port
    if (s.size >= 2 && s[0] == b'[') {
        if (let Some(end) <- s.indexOf(b']')) {
            let hostPart = s[1..end]
            let rest = s[end + 1..]
            return if (!rest.isEmpty() && rest[0] == b':') {
                (hostPart, rest[1..])
            } else {
                (hostPart, "")
            }
        }
    }

    // host:port only when there is exactly one ':'
    let firstColon = s.indexOf(b':')
    let lastColon = s.lastIndexOf(b':')
    match ((firstColon, lastColon)) {
        case (Some(first), Some(last)) where first == last =>
            let hostPart = s[..first]
            let portPart = s[first + 1..]
            return (hostPart, portPart)
        case _ => (s, "")
    }
}

func isIpLiteral(hostLower: String): Bool {
    // IPv6 literal (basic heuristic)
    if (let Some(_) <- hostLower.indexOf(b':')) {
        return true
    }

    // IPv4 heuristic: only digits/dots AND contains at least one dot
    var hasDot = false
    for (r in hostLower) {
        if (r == b'.') {
            hasDot = true
            continue
        }
        if (!r.isAsciiNumber()) {
            return false
        }
    }
    return hasDot
}

func hostMatchesDomain(hostLower: String, domainLower: String): Bool {
    var d = domainLower
    if (d.endsWith(".")) { // trailing dot (FQDN)
        d = d[..d.size - 1]
    }

    let domain = if (d.startsWith(".")) { // remove leading dot
        d[1..]
    } else {
        d
    }
    if (domain.isEmpty()) {
        return false
    }
    if (hostLower == domain) {
        return true
    }
    return hostLower.endsWith("." + domain)
}

func getNoProxy(): ?Array<String> {
    let noProxy = getVariable("no_proxy") ?? getVariable("NO_PROXY") ?? ""
    if (!noProxy.isEmpty()) {
        return noProxy.split(",")
    }
    return None
}

func matchNoProxy(host: String, port: String): Bool {
    let hostLower = normalizeHostLower(host)
    let noProxy = getNoProxy()
    if (noProxy.isNone()) {
        return false
    }

    for (item in noProxy.getOrThrow()) {
        let domain = item.trimAscii()
        if (domain.isEmpty()) {
            continue
        }
        if (domain == ASTERISK) {
            return true
        }

        let (noProxyHostRaw, noProxyPortRaw) = splitNoProxyHostPort(domain)
        let noProxyHost = noProxyHostRaw.trimAscii()
        if (noProxyHost.isEmpty()) {
            continue
        }
        let noProxyPort = noProxyPortRaw.trimAscii()
        if (!noProxyPort.isEmpty() && noProxyPort != port) {
            continue
        }

        let noProxyHostLower = normalizeHostLower(noProxyHost)
        if (isIpLiteral(noProxyHostLower)) {
            if (noProxyHostLower == hostLower) {
                return true
            }
            continue
        }

        if (hostMatchesDomain(hostLower, noProxyHostLower)) {
            return true
        }
    }
    return false
}